3 Commits

Author SHA1 Message Date
44f007936e Merge remote-tracking branch 'origin/feature/redis-auth-integration' into dev
# Conflicts:
#	go.mod
#	go.sum
#	internal/container/container.go
#	internal/repository/interfaces.go
#	internal/service/mocks_test.go
#	internal/service/texture_service_test.go
#	internal/service/token_service_test.go
#	pkg/redis/manager.go
2025-12-25 22:45:58 +08:00
lan
6ddcf92ce3 refactor: Remove Token management and integrate Redis for authentication
- Deleted the Token model and its repository, transitioning to a Redis-based token management system.
- Updated the service layer to utilize Redis for token storage, enhancing performance and scalability.
- Refactored the container to remove TokenRepository and integrate the new token service.
- Cleaned up the Dockerfile and other files by removing unnecessary whitespace and comments.
- Enhanced error handling and logging for Redis initialization and usage.
2025-12-24 16:03:46 +08:00
9b0a60033e 删除服务端材质渲染功能及system_config表,转为环境变量配置,初步配置管理员功能 2025-12-08 19:12:30 +08:00
65 changed files with 2882 additions and 4930 deletions

View File

@@ -2,6 +2,20 @@
# 复制此文件为 .env 后修改配置值 # 复制此文件为 .env 后修改配置值
# 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致 # 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致
# ==================== 站点配置 ====================
SITE_NAME=CarrotSkin
SITE_DESCRIPTION=一个优秀的Minecraft皮肤站
REGISTRATION_ENABLED=true
DEFAULT_AVATAR=
# ==================== 用户限制配置 ====================
MAX_TEXTURES_PER_USER=50
MAX_PROFILES_PER_USER=5
# ==================== 积分配置 ====================
CHECKIN_REWARD=10
TEXTURE_DOWNLOAD_REWARD=1
# ==================== 服务配置 ==================== # ==================== 服务配置 ====================
# 应用对外端口 # 应用对外端口
APP_PORT=8080 APP_PORT=8080

View File

@@ -1,6 +1,26 @@
# CarrotSkin 环境配置文件示例 # CarrotSkin 环境配置文件示例
# 复制此文件为 .env 并修改相应的配置值 # 复制此文件为 .env 并修改相应的配置值
# =============================================================================
# 站点配置
# =============================================================================
SITE_NAME=CarrotSkin
SITE_DESCRIPTION=一个优秀的Minecraft皮肤站
REGISTRATION_ENABLED=true
DEFAULT_AVATAR=
# =============================================================================
# 用户限制配置
# =============================================================================
MAX_TEXTURES_PER_USER=50
MAX_PROFILES_PER_USER=5
# =============================================================================
# 积分配置
# =============================================================================
CHECKIN_REWARD=10
TEXTURE_DOWNLOAD_REWARD=1
# ============================================================================= # =============================================================================
# 服务器配置 # 服务器配置
# ============================================================================= # =============================================================================

View File

@@ -1,74 +0,0 @@
# ==================== 构建阶段 ====================
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

@@ -21,6 +21,7 @@ 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"
@@ -70,11 +71,18 @@ func main() {
loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err)) loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err))
} }
// 初始化Redis // 初始化Redis(开发/测试环境失败时会自动回退到miniredis
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
@@ -91,12 +99,19 @@ func main() {
} }
emailServiceInstance := email.MustGetService() emailServiceInstance := email.MustGetService()
// 初始化Casbin权限服务
casbinService, err := auth.NewCasbinService(database.MustGetDB(), cfg.Casbin.ModelPath, loggerInstance)
if err != nil {
loggerInstance.Fatal("Casbin服务初始化失败", zap.Error(err))
}
// 创建依赖注入容器 // 创建依赖注入容器
c := container.NewContainer( c := container.NewContainer(
database.MustGetDB(), database.MustGetDB(),
redis.MustGetClient(), redis.MustGetClient(),
loggerInstance, loggerInstance,
auth.MustGetJWTService(), auth.MustGetJWTService(),
casbinService,
storageClient, storageClient,
emailServiceInstance, emailServiceInstance,
) )
@@ -121,6 +136,13 @@ 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,
@@ -143,6 +165,10 @@ 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

@@ -11,4 +11,4 @@ g = _, _
e = some(where (p.eft == allow)) e = some(where (p.eft == allow))
[matchers] [matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act m = g(r.sub, p.sub) && (p.obj == "*" || r.obj == p.obj) && (p.act == "*" || r.act == p.act)

View File

@@ -12,6 +12,17 @@ services:
ports: ports:
- "${APP_PORT:-8080}:8080" - "${APP_PORT:-8080}:8080"
environment: environment:
# 站点配置
- SITE_NAME=${SITE_NAME:-CarrotSkin}
- SITE_DESCRIPTION=${SITE_DESCRIPTION:-一个优秀的Minecraft皮肤站}
- REGISTRATION_ENABLED=${REGISTRATION_ENABLED:-true}
- DEFAULT_AVATAR=${DEFAULT_AVATAR:-}
# 用户限制配置
- MAX_TEXTURES_PER_USER=${MAX_TEXTURES_PER_USER:-50}
- MAX_PROFILES_PER_USER=${MAX_PROFILES_PER_USER:-5}
# 积分配置
- CHECKIN_REWARD=${CHECKIN_REWARD:-10}
- TEXTURE_DOWNLOAD_REWARD=${TEXTURE_DOWNLOAD_REWARD:-1}
# 服务器配置 # 服务器配置
- SERVER_PORT=:8080 - SERVER_PORT=:8080
- SERVER_MODE=${SERVER_MODE:-release} - SERVER_MODE=${SERVER_MODE:-release}

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/chai2010/webp v1.4.0 github.com/alicebob/miniredis/v2 v2.31.1
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,45 +13,36 @@ 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/KyleBanks/depth v1.2.1 // indirect github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // 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.19.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect github.com/mattn/go-sqlite3 v1.14.22 // 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.6.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
go.uber.org/mock v0.6.0 // indirect github.com/tinylib/msgp v1.3.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,7 +1,10 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
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=
@@ -14,8 +17,9 @@ github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2N
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
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=
@@ -31,41 +35,12 @@ 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=
@@ -80,8 +55,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.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.18.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=
@@ -90,6 +65,7 @@ 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=
@@ -132,8 +108,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.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/crc64nvme v1.1.0/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=
@@ -149,10 +125,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.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
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=
@@ -183,14 +159,8 @@ 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/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
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=
@@ -200,10 +170,12 @@ 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.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
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=
@@ -227,7 +199,6 @@ 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=
@@ -235,6 +206,7 @@ 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=
@@ -254,8 +226,6 @@ 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,7 +29,6 @@ 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
@@ -41,10 +40,10 @@ type Container struct {
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 创建依赖容器
@@ -61,6 +60,14 @@ 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{
@@ -76,7 +83,6 @@ 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)
@@ -86,10 +92,9 @@ func NewContainer(
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger) c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
// 初始化Service注入缓存管理器 // 初始化Service注入缓存管理器
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, storageClient, logger) c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, 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
@@ -99,13 +104,28 @@ 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.TokenRepo, c.YggdrasilRepo, c.SignatureService, redisClient, logger) c.YggdrasilService = service.NewYggdrasilServiceComposite(db, c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.SignatureService, redisClient, logger, c.TokenService)
// 初始化其他服务 // 初始化其他服务
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
@@ -186,13 +206,6 @@ 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) {
@@ -249,6 +262,13 @@ 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

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

@@ -0,0 +1,366 @@
package handler
import (
"net/http"
"strconv"
"carrotskin/internal/container"
"carrotskin/internal/model"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AdminHandler 管理员处理器
type AdminHandler struct {
container *container.Container
}
// NewAdminHandler 创建管理员处理器
func NewAdminHandler(c *container.Container) *AdminHandler {
return &AdminHandler{container: c}
}
// SetUserRoleRequest 设置用户角色请求
type SetUserRoleRequest struct {
UserID int64 `json:"user_id" binding:"required"`
Role string `json:"role" binding:"required,oneof=user admin"`
}
// SetUserRole 设置用户角色
// @Summary 设置用户角色
// @Description 管理员设置指定用户的角色
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body SetUserRoleRequest true "设置角色请求"
// @Success 200 {object} model.Response
// @Failure 400 {object} model.Response
// @Failure 403 {object} model.Response
// @Security BearerAuth
// @Router /admin/users/role [put]
func (h *AdminHandler) SetUserRole(c *gin.Context) {
var req SetUserRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 获取当前操作者ID
operatorID, _ := c.Get("user_id")
// 不能修改自己的角色
if req.UserID == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能修改自己的角色",
nil,
))
return
}
// 检查目标用户是否存在
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
if err != nil || targetUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
nil,
))
return
}
// 更新用户角色
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
"role": req.Role,
})
if err != nil {
RespondServerError(c, "更新用户角色失败", err)
return
}
h.container.Logger.Info("管理员修改用户角色",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("target_user_id", req.UserID),
zap.String("new_role", req.Role),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "用户角色更新成功",
"user_id": req.UserID,
"role": req.Role,
}))
}
// GetUserList 获取用户列表
// @Summary 获取用户列表
// @Description 管理员获取所有用户列表
// @Tags Admin
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response
// @Failure 403 {object} model.Response
// @Security BearerAuth
// @Router /admin/users [get]
func (h *AdminHandler) GetUserList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
// 使用数据库直接查询用户列表
var users []model.User
var total int64
db := h.container.DB
db.Model(&model.User{}).Count(&total)
db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&users)
// 构建响应(隐藏敏感信息)
userList := make([]gin.H, len(users))
for i, u := range users {
userList[i] = gin.H{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"avatar": u.Avatar,
"role": u.Role,
"status": u.Status,
"points": u.Points,
"last_login_at": u.LastLoginAt,
"created_at": u.CreatedAt,
}
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"users": userList,
"total": total,
"page": page,
"page_size": pageSize,
}))
}
// GetUserDetail 获取用户详情
// @Summary 获取用户详情
// @Description 管理员获取指定用户的详细信息
// @Tags Admin
// @Produce json
// @Param id path int true "用户ID"
// @Success 200 {object} model.Response
// @Failure 404 {object} model.Response
// @Security BearerAuth
// @Router /admin/users/{id} [get]
func (h *AdminHandler) GetUserDetail(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的用户ID", err)
return
}
user, err := h.container.UserRepo.FindByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
nil,
))
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"role": user.Role,
"status": user.Status,
"points": user.Points,
"properties": user.Properties,
"last_login_at": user.LastLoginAt,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
}))
}
// SetUserStatusRequest 设置用户状态请求
type SetUserStatusRequest struct {
UserID int64 `json:"user_id" binding:"required"`
Status int16 `json:"status" binding:"required,oneof=1 0 -1"` // 1:正常, 0:禁用, -1:删除
}
// SetUserStatus 设置用户状态
// @Summary 设置用户状态
// @Description 管理员设置用户状态(启用/禁用)
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body SetUserStatusRequest true "设置状态请求"
// @Success 200 {object} model.Response
// @Failure 400 {object} model.Response
// @Security BearerAuth
// @Router /admin/users/status [put]
func (h *AdminHandler) SetUserStatus(c *gin.Context) {
var req SetUserStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
operatorID, _ := c.Get("user_id")
// 不能修改自己的状态
if req.UserID == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能修改自己的状态",
nil,
))
return
}
// 检查目标用户是否存在
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
if err != nil || targetUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
nil,
))
return
}
// 更新用户状态
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
"status": req.Status,
})
if err != nil {
RespondServerError(c, "更新用户状态失败", err)
return
}
statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status]
h.container.Logger.Info("管理员修改用户状态",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("target_user_id", req.UserID),
zap.Int16("new_status", req.Status),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "用户状态更新成功",
"user_id": req.UserID,
"status": req.Status,
"status_text": statusText,
}))
}
// DeleteTexture 管理员删除材质
// @Summary 管理员删除材质
// @Description 管理员可以删除任意材质(用于审核不当内容)
// @Tags Admin
// @Produce json
// @Param id path int true "材质ID"
// @Success 200 {object} model.Response
// @Failure 404 {object} model.Response
// @Security BearerAuth
// @Router /admin/textures/{id} [delete]
func (h *AdminHandler) DeleteTexture(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
operatorID, _ := c.Get("user_id")
// 检查材质是否存在
var texture model.Texture
if err := h.container.DB.First(&texture, textureID).Error; err != nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"材质不存在",
nil,
))
return
}
// 删除材质
if err := h.container.DB.Delete(&texture).Error; err != nil {
RespondServerError(c, "删除材质失败", err)
return
}
h.container.Logger.Info("管理员删除材质",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("texture_id", textureID),
zap.Int64("uploader_id", texture.UploaderID),
zap.String("texture_name", texture.Name),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "材质删除成功",
"texture_id": textureID,
}))
}
// GetTextureList 管理员获取材质列表
// @Summary 管理员获取材质列表
// @Description 管理员获取所有材质列表(用于审核)
// @Tags Admin
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response
// @Security BearerAuth
// @Router /admin/textures [get]
func (h *AdminHandler) GetTextureList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
var textures []model.Texture
var total int64
db := h.container.DB
db.Model(&model.Texture{}).Count(&total)
db.Preload("Uploader").Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&textures)
// 构建响应
textureList := make([]gin.H, len(textures))
for i, t := range textures {
uploaderName := ""
if t.Uploader != nil {
uploaderName = t.Uploader.Username
}
textureList[i] = gin.H{
"id": t.ID,
"name": t.Name,
"type": t.Type,
"hash": t.Hash,
"uploader_id": t.UploaderID,
"uploader_name": uploaderName,
"is_public": t.IsPublic,
"download_count": t.DownloadCount,
"created_at": t.CreatedAt,
}
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"textures": textureList,
"total": total,
"page": page,
"page_size": pageSize,
}))
}

View File

@@ -5,6 +5,7 @@ import (
"carrotskin/internal/middleware" "carrotskin/internal/middleware"
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"carrotskin/pkg/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files" swaggerFiles "github.com/swaggo/files"
@@ -20,6 +21,7 @@ type Handlers struct {
Captcha *CaptchaHandler Captcha *CaptchaHandler
Yggdrasil *YggdrasilHandler Yggdrasil *YggdrasilHandler
CustomSkin *CustomSkinHandler CustomSkin *CustomSkinHandler
Admin *AdminHandler
} }
// NewHandlers 创建所有Handler实例 // NewHandlers 创建所有Handler实例
@@ -32,6 +34,7 @@ func NewHandlers(c *container.Container) *Handlers {
Captcha: NewCaptchaHandler(c), Captcha: NewCaptchaHandler(c),
Yggdrasil: NewYggdrasilHandler(c), Yggdrasil: NewYggdrasilHandler(c),
CustomSkin: NewCustomSkinHandler(c), CustomSkin: NewCustomSkinHandler(c),
Admin: NewAdminHandler(c),
} }
} }
@@ -68,10 +71,13 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
registerYggdrasilRoutesWithDI(v1, h.Yggdrasil) registerYggdrasilRoutesWithDI(v1, h.Yggdrasil)
// 系统路由 // 系统路由
registerSystemRoutes(v1) registerSystemRoutes(v1, c)
// CustomSkinAPI 路由 // CustomSkinAPI 路由
registerCustomSkinRoutes(v1, h.CustomSkin) registerCustomSkinRoutes(v1, h.CustomSkin)
// 管理员路由(需要管理员权限)
registerAdminRoutes(v1, c, h.Admin)
} }
} }
@@ -113,10 +119,6 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a
// 公开路由(无需认证) // 公开路由(无需认证)
textureGroup.GET("", h.Search) textureGroup.GET("", h.Search)
textureGroup.GET("/:id", h.Get) textureGroup.GET("/:id", h.Get)
textureGroup.GET("/:id/render", h.RenderTexture) // type/front/back/full/head/isometric
textureGroup.GET("/:id/avatar", h.RenderAvatar) // mode=2d/3d
textureGroup.GET("/:id/cape", h.RenderCape)
textureGroup.GET("/:id/preview", h.RenderPreview) // 自动根据类型预览
// 需要认证的路由 // 需要认证的路由
textureAuth := textureGroup.Group("") textureAuth := textureGroup.Group("")
@@ -192,17 +194,46 @@ func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *YggdrasilHandler) {
} }
// registerSystemRoutes 注册系统路由 // registerSystemRoutes 注册系统路由
func registerSystemRoutes(v1 *gin.RouterGroup) { func registerSystemRoutes(v1 *gin.RouterGroup, c *container.Container) {
system := v1.Group("/system") system := v1.Group("/system")
{ {
system.GET("/config", func(c *gin.Context) { // 公开配置(无需认证)
// TODO: 实现从数据库读取系统配置 system.GET("/config", func(ctx *gin.Context) {
c.JSON(200, model.NewSuccessResponse(gin.H{ cfg, _ := config.GetConfig()
"site_name": "CarrotSkin", ctx.JSON(200, model.NewSuccessResponse(gin.H{
"site_description": "A Minecraft Skin Station", "site_name": cfg.Site.Name,
"registration_enabled": true, "site_description": cfg.Site.Description,
"max_textures_per_user": 100, "registration_enabled": cfg.Site.RegistrationEnabled,
"max_profiles_per_user": 5, "max_textures_per_user": cfg.Site.MaxTexturesPerUser,
"max_profiles_per_user": cfg.Site.MaxProfilesPerUser,
}))
})
}
}
// registerAdminRoutes 注册管理员路由
func registerAdminRoutes(v1 *gin.RouterGroup, c *container.Container, h *AdminHandler) {
admin := v1.Group("/admin")
admin.Use(middleware.AuthMiddleware(c.JWT))
admin.Use(middleware.RequireAdmin())
{
// 用户管理
admin.GET("/users", h.GetUserList)
admin.GET("/users/:id", h.GetUserDetail)
admin.PUT("/users/role", h.SetUserRole)
admin.PUT("/users/status", h.SetUserStatus)
// 材质管理(审核)
admin.GET("/textures", h.GetTextureList)
admin.DELETE("/textures/:id", h.DeleteTexture)
// 权限管理
admin.GET("/permissions", func(ctx *gin.Context) {
// 获取所有权限规则
policies, _ := c.Casbin.GetEnforcer().GetPolicy()
ctx.JSON(200, model.NewSuccessResponse(gin.H{
"policies": policies,
})) }))
}) })
} }

View File

@@ -0,0 +1,27 @@
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,7 +3,6 @@ package handler
import ( import (
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/internal/types" "carrotskin/internal/types"
"strconv" "strconv"
@@ -85,98 +84,6 @@ func (h *TextureHandler) Search(c *gin.Context) {
}) })
} }
// RenderTexture 渲染皮肤/披风预览
func (h *TextureHandler) RenderTexture(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
renderType := service.RenderType(c.DefaultQuery("type", string(service.RenderTypeIsometric)))
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
result, err := h.container.TextureRenderService.RenderTexture(c.Request.Context(), textureID, renderType, size, format)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, toRenderResponse(result))
}
// RenderAvatar 渲染头像2D/3D
func (h *TextureHandler) RenderAvatar(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
mode := service.AvatarMode(c.DefaultQuery("mode", string(service.AvatarMode2D)))
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
result, err := h.container.TextureRenderService.RenderAvatar(c.Request.Context(), textureID, size, mode, format)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, toRenderResponse(result))
}
// RenderCape 渲染披风
func (h *TextureHandler) RenderCape(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
result, err := h.container.TextureRenderService.RenderCape(c.Request.Context(), textureID, size, format)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, toRenderResponse(result))
}
// RenderPreview 自动选择预览(皮肤走等距,披风走披风渲染)
func (h *TextureHandler) RenderPreview(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
result, err := h.container.TextureRenderService.RenderPreview(c.Request.Context(), textureID, size, format)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, toRenderResponse(result))
}
// toRenderResponse 转换为API响应
func toRenderResponse(r *service.RenderResult) *types.RenderResponse {
if r == nil {
return nil
}
resp := &types.RenderResponse{
URL: r.URL,
ContentType: r.ContentType,
ETag: r.ETag,
Size: r.Size,
}
if !r.LastModified.IsZero() {
t := r.LastModified
resp.LastModified = &t
}
return resp
}
// Update 更新材质 // Update 更新材质
func (h *TextureHandler) Update(c *gin.Context) { func (h *TextureHandler) Update(c *gin.Context) {
userID, ok := GetUserIDFromContext(c) userID, ok := GetUserIDFromContext(c)

View File

@@ -51,7 +51,7 @@ func AuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
// 将用户信息存储到上下文中 // 将用户信息存储到上下文中
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("username", claims.Username) c.Set("username", claims.Username)
c.Set("role", claims.Role) c.Set("user_role", claims.Role)
c.Next() c.Next()
}) })
@@ -69,7 +69,7 @@ func OptionalAuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
if err == nil { if err == nil {
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("username", claims.Username) c.Set("username", claims.Username)
c.Set("role", claims.Role) c.Set("user_role", claims.Role)
} }
} }
} }

View File

@@ -0,0 +1,109 @@
package middleware
import (
"net/http"
"carrotskin/pkg/auth"
"github.com/gin-gonic/gin"
)
// CasbinMiddleware Casbin权限中间件
// 需要先经过AuthMiddleware获取用户信息
func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string) gin.HandlerFunc {
return func(c *gin.Context) {
// 从上下文获取用户角色由AuthMiddleware设置
role, exists := c.Get("user_role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
c.Abort()
return
}
roleStr, ok := role.(string)
if !ok || roleStr == "" {
roleStr = "user" // 默认角色
}
// 检查权限
if !casbinService.CheckPermission(roleStr, resource, action) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "权限不足",
})
c.Abort()
return
}
c.Next()
}
}
// RequireAdmin 要求管理员权限的中间件
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("user_role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
c.Abort()
return
}
roleStr, ok := role.(string)
if !ok || roleStr != "admin" {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "需要管理员权限",
})
c.Abort()
return
}
c.Next()
}
}
// RequireRole 要求指定角色的中间件
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("user_role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
c.Abort()
return
}
roleStr, ok := role.(string)
if !ok {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "权限不足",
})
c.Abort()
return
}
// 检查是否在允许的角色列表中
for _, allowed := range allowedRoles {
if roleStr == allowed {
c.Next()
return
}
}
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "权限不足",
})
c.Abort()
}
}

View File

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

View File

@@ -1,41 +0,0 @@
package model
import (
"time"
)
// ConfigType 配置类型
type ConfigType string
const (
ConfigTypeString ConfigType = "STRING"
ConfigTypeInteger ConfigType = "INTEGER"
ConfigTypeBoolean ConfigType = "BOOLEAN"
ConfigTypeJSON ConfigType = "JSON"
)
// SystemConfig 系统配置模型
type SystemConfig struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Key string `gorm:"column:key;type:varchar(100);not null;uniqueIndex" json:"key"`
Value string `gorm:"column:value;type:text;not null" json:"value"`
Description string `gorm:"column:description;type:varchar(255);not null;default:''" json:"description"`
Type ConfigType `gorm:"column:type;type:varchar(50);not null;default:'STRING'" json:"type"` // STRING, INTEGER, BOOLEAN, JSON
IsPublic bool `gorm:"column:is_public;not null;default:false;index" json:"is_public"` // 是否可被前端获取
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
}
// TableName 指定表名
func (SystemConfig) TableName() string {
return "system_config"
}
// SystemConfigPublicResponse 公开配置响应
type SystemConfigPublicResponse struct {
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
RegistrationEnabled bool `json:"registration_enabled"`
MaintenanceMode bool `json:"maintenance_mode"`
Announcement string `json:"announcement"`
}

View File

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

@@ -0,0 +1,18 @@
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,6 +35,7 @@ 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)
@@ -66,18 +67,6 @@ 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

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

@@ -1,44 +0,0 @@
package repository
import (
"carrotskin/internal/model"
"context"
"gorm.io/gorm"
)
// systemConfigRepository SystemConfigRepository的实现
type systemConfigRepository struct {
db *gorm.DB
}
// NewSystemConfigRepository 创建SystemConfigRepository实例
func NewSystemConfigRepository(db *gorm.DB) SystemConfigRepository {
return &systemConfigRepository{db: db}
}
func (r *systemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) {
var config model.SystemConfig
err := r.db.WithContext(ctx).Where("key = ?", key).First(&config).Error
return handleNotFoundResult(&config, err)
}
func (r *systemConfigRepository) GetPublic(ctx context.Context) ([]model.SystemConfig, error) {
var configs []model.SystemConfig
err := r.db.WithContext(ctx).Where("is_public = ?", true).Find(&configs).Error
return configs, err
}
func (r *systemConfigRepository) GetAll(ctx context.Context) ([]model.SystemConfig, error) {
var configs []model.SystemConfig
err := r.db.WithContext(ctx).Find(&configs).Error
return configs, err
}
func (r *systemConfigRepository) Update(ctx context.Context, config *model.SystemConfig) error {
return r.db.WithContext(ctx).Save(config).Error
}
func (r *systemConfigRepository) UpdateValue(ctx context.Context, key, value string) error {
return r.db.WithContext(ctx).Model(&model.SystemConfig{}).Where("key = ?", key).Update("value", value).Error
}

View File

@@ -1,146 +0,0 @@
package repository
import (
"testing"
)
// TestSystemConfigRepository_QueryConditions 测试系统配置查询条件逻辑
func TestSystemConfigRepository_QueryConditions(t *testing.T) {
tests := []struct {
name string
key string
isPublic bool
wantValid bool
}{
{
name: "有效的配置键",
key: "site_name",
isPublic: true,
wantValid: true,
},
{
name: "配置键为空",
key: "",
isPublic: true,
wantValid: false,
},
{
name: "公开配置查询",
key: "site_name",
isPublic: true,
wantValid: true,
},
{
name: "私有配置查询",
key: "secret_key",
isPublic: false,
wantValid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.key != ""
if isValid != tt.wantValid {
t.Errorf("Query condition validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestSystemConfigRepository_PublicConfigLogic 测试公开配置逻辑
func TestSystemConfigRepository_PublicConfigLogic(t *testing.T) {
tests := []struct {
name string
isPublic bool
wantInclude bool
}{
{
name: "只获取公开配置",
isPublic: true,
wantInclude: true,
},
{
name: "私有配置不应包含",
isPublic: false,
wantInclude: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证逻辑GetPublicSystemConfigs应该只返回is_public=true的配置
if tt.isPublic != tt.wantInclude {
t.Errorf("Public config logic failed: isPublic=%v, wantInclude=%v", tt.isPublic, tt.wantInclude)
}
})
}
}
// TestSystemConfigRepository_UpdateValueLogic 测试更新配置值逻辑
func TestSystemConfigRepository_UpdateValueLogic(t *testing.T) {
tests := []struct {
name string
key string
value string
wantValid bool
}{
{
name: "有效的键值对",
key: "site_name",
value: "CarrotSkin",
wantValid: true,
},
{
name: "键为空",
key: "",
value: "CarrotSkin",
wantValid: false,
},
{
name: "值为空(可能有效)",
key: "site_name",
value: "",
wantValid: true, // 空值也可能是有效的
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.key != ""
if isValid != tt.wantValid {
t.Errorf("Update value validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestSystemConfigRepository_ErrorHandling 测试错误处理逻辑
func TestSystemConfigRepository_ErrorHandling(t *testing.T) {
tests := []struct {
name string
isNotFound bool
wantNilConfig bool
}{
{
name: "记录未找到应该返回nil配置",
isNotFound: true,
wantNilConfig: true,
},
{
name: "找到记录应该返回配置",
isNotFound: false,
wantNilConfig: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证错误处理逻辑如果是RecordNotFound返回nil配置
if tt.isNotFound != tt.wantNilConfig {
t.Errorf("Error handling logic failed: isNotFound=%v, wantNilConfig=%v", tt.isNotFound, tt.wantNilConfig)
}
})
}
}

View File

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

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

@@ -136,69 +136,6 @@ type SecurityService interface {
ClearVerifyAttempts(ctx context.Context, email, codeType string) error ClearVerifyAttempts(ctx context.Context, email, codeType string) error
} }
// TextureRenderService 纹理渲染服务接口
type TextureRenderService interface {
// RenderTexture 渲染纹理为预览图
RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error)
// RenderTextureFromData 从原始数据渲染纹理
RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error)
// GetRenderURL 获取渲染图的URL
GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string
// DeleteRenderCache 删除渲染缓存
DeleteRenderCache(ctx context.Context, textureID int64) error
// RenderAvatar 渲染头像支持2D/3D模式
RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error)
// RenderCape 渲染披风
RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
// RenderPreview 渲染预览图类似Blessing Skin的preview功能
RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
}
// RenderType 渲染类型
type RenderType string
const (
RenderTypeFront RenderType = "front" // 正面
RenderTypeBack RenderType = "back" // 背面
RenderTypeFull RenderType = "full" // 全身
RenderTypeHead RenderType = "head" // 头像
RenderTypeIsometric RenderType = "isometric" // 等距视图
)
// ImageFormat 输出格式
type ImageFormat string
const (
ImageFormatPNG ImageFormat = "png"
ImageFormatWEBP ImageFormat = "webp"
)
// AvatarMode 头像模式
type AvatarMode string
const (
AvatarMode2D AvatarMode = "2d" // 2D头像
AvatarMode3D AvatarMode = "3d" // 3D头像
)
// TextureType 纹理类型
type TextureType string
const (
TextureTypeSteve TextureType = "steve" // Steve皮肤
TextureTypeAlex TextureType = "alex" // Alex皮肤
TextureTypeCape TextureType = "cape" // 披风
)
// RenderResult 渲染结果(附带缓存/HTTP头信息
type RenderResult struct {
URL string
ContentType string
ETag string
LastModified time.Time
Size int64
}
// Services 服务集合 // Services 服务集合
type Services struct { type Services struct {
User UserService User UserService

View File

@@ -214,6 +214,10 @@ 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
} }
@@ -470,101 +474,6 @@ 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
@@ -804,6 +713,10 @@ 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 {
@@ -960,90 +873,11 @@ 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 - uses database.CacheManager with nil redis // CacheManager Mock - 使用 database.CacheManager 的内存版本
// ============================================================================ // ============================================================================
// 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,7 +11,6 @@ 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"
@@ -93,7 +92,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 err := s.cache.Get(ctx, cacheKey, &profile); err == nil { if ok, _ := s.cache.TryGet(ctx, cacheKey, &profile); ok {
return &profile, nil return &profile, nil
} }
@@ -106,11 +105,9 @@ 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 {
go func() { s.cache.SetAsync(context.Background(), cacheKey, profile2, s.cache.Policy.ProfileTTL)
_ = s.cache.Set(context.Background(), cacheKey, profile2, 5*time.Minute)
}()
} }
return profile2, nil return profile2, nil
@@ -120,7 +117,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 err := s.cache.Get(ctx, cacheKey, &profiles); err == nil { if ok, _ := s.cache.TryGet(ctx, cacheKey, &profiles); ok {
return profiles, nil return profiles, nil
} }
@@ -130,11 +127,9 @@ 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 {
go func() { s.cache.SetAsync(context.Background(), cacheKey, profiles, s.cache.Policy.ProfileListTTL)
_ = s.cache.Set(context.Background(), cacheKey, profiles, 3*time.Minute)
}()
} }
return profiles, nil return profiles, nil

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -52,7 +51,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
// 尝试从缓存获取 // 尝试从缓存获取
cacheKey := s.cacheKeys.Texture(id) cacheKey := s.cacheKeys.Texture(id)
var texture model.Texture var texture model.Texture
if err := s.cache.Get(ctx, cacheKey, &texture); err == nil { if ok, _ := s.cache.TryGet(ctx, cacheKey, &texture); ok {
if texture.Status == -1 { if texture.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
} }
@@ -71,11 +70,9 @@ 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 {
go func() { s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
_ = s.cache.Set(context.Background(), cacheKey, texture2, 5*time.Minute)
}()
} }
return texture2, nil return texture2, nil
@@ -85,7 +82,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 err := s.cache.Get(ctx, cacheKey, &texture); err == nil { if ok, _ := s.cache.TryGet(ctx, cacheKey, &texture); ok {
if texture.Status == -1 { if texture.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
} }
@@ -104,10 +101,8 @@ func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Tex
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
} }
// 存入缓存(异步5分钟过期 // 存入缓存(异步)
go func() { s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
_ = s.cache.Set(context.Background(), cacheKey, texture2, 5*time.Minute)
}()
return texture2, nil return texture2, nil
} }
@@ -121,7 +116,7 @@ func (s *textureService) GetByUserID(ctx context.Context, uploaderID int64, page
Textures []*model.Texture Textures []*model.Texture
Total int64 Total int64
} }
if err := s.cache.Get(ctx, cacheKey, &cachedResult); err == nil { if ok, _ := s.cache.TryGet(ctx, cacheKey, &cachedResult); ok {
return cachedResult.Textures, cachedResult.Total, nil return cachedResult.Textures, cachedResult.Total, nil
} }
@@ -131,14 +126,12 @@ func (s *textureService) GetByUserID(ctx context.Context, uploaderID int64, page
return nil, 0, err return nil, 0, err
} }
// 存入缓存(异步2分钟过期 // 存入缓存(异步)
go func() {
result := struct { result := struct {
Textures []*model.Texture Textures []*model.Texture
Total int64 Total int64
}{Textures: textures, Total: total} }{Textures: textures, Total: total}
_ = s.cache.Set(context.Background(), cacheKey, result, 2*time.Minute) s.cache.SetAsync(context.Background(), cacheKey, result, s.cache.Policy.TextureListTTL)
}()
return textures, total, nil return textures, total, nil
} }
@@ -181,7 +174,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, fmt.Sprintf("texture:user:%d:*", uploaderID)) s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.TextureListPattern(uploaderID))
return s.textureRepo.FindByID(ctx, textureID) return s.textureRepo.FindByID(ctx, textureID)
} }
@@ -206,7 +199,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, fmt.Sprintf("texture:user:%d:*", uploaderID)) s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.TextureListPattern(uploaderID))
return nil return nil
} }

View File

@@ -478,6 +478,128 @@ 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

@@ -1,305 +0,0 @@
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,7 +7,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strconv"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -15,9 +14,9 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// tokenServiceJWT TokenService的JWT实现使用JWT + Version机制 // tokenServiceRedis TokenService的Redis实现
type tokenServiceJWT struct { type tokenServiceRedis struct {
tokenRepo repository.TokenRepository tokenStore *auth.TokenStoreRedis
clientRepo repository.ClientRepository clientRepo repository.ClientRepository
profileRepo repository.ProfileRepository profileRepo repository.ProfileRepository
yggdrasilJWT *auth.YggdrasilJWTService yggdrasilJWT *auth.YggdrasilJWTService
@@ -26,16 +25,16 @@ type tokenServiceJWT struct {
tokenStaleSec int64 // Token过期但可用时间0表示永不过期 tokenStaleSec int64 // Token过期但可用时间0表示永不过期
} }
// NewTokenServiceJWT 创建使用JWT的TokenService实例 // NewTokenServiceRedis 创建使用Redis的TokenService实例
func NewTokenServiceJWT( func NewTokenServiceRedis(
tokenRepo repository.TokenRepository, tokenStore *auth.TokenStoreRedis,
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 &tokenServiceJWT{ return &tokenServiceRedis{
tokenRepo: tokenRepo, tokenStore: tokenStore,
clientRepo: clientRepo, clientRepo: clientRepo,
profileRepo: profileRepo, profileRepo: profileRepo,
yggdrasilJWT: yggdrasilJWT, yggdrasilJWT: yggdrasilJWT,
@@ -45,10 +44,8 @@ func NewTokenServiceJWT(
} }
} }
// 常量已在 token_service.go 中定义,这里不重复定义 // Create 创建Token使用JWT + Redis存储
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
@@ -134,7 +131,7 @@ func (s *tokenServiceJWT) Create(ctx context.Context, userID int64, UUID string,
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)
} }
@@ -157,36 +154,31 @@ func (s *tokenServiceJWT) Create(ctx context.Context, userID int64, UUID string,
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成AccessToken失败: %w", err) return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成AccessToken失败: %w", err)
} }
// 存Token记录(用于查询和审计) // 存Token到Redis
token := model.Token{ ttl := expiresAt.Sub(now)
AccessToken: accessToken, metadata := &auth.TokenMetadata{
ClientToken: clientToken,
UserID: userID, UserID: userID,
ProfileId: profileID, ProfileID: profileID,
ClientUUID: client.UUID,
ClientToken: client.ClientToken,
Version: client.Version, Version: client.Version,
Usable: true, CreatedAt: now.Unix(),
IssueDate: now,
ExpiresAt: &expiresAt,
StaleAt: &staleAt,
} }
err = s.tokenRepo.Create(ctx, &token) if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil {
if err != nil { s.logger.Warn("存储Token到Redis失败", zap.Error(err))
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验证 // Validate 验证Token使用JWT验证 + Redis存储验证
func (s *tokenServiceJWT) Validate(ctx context.Context, accessToken, clientToken string) bool { func (s *tokenServiceRedis) 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
} }
@@ -197,6 +189,13 @@ func (s *tokenServiceJWT) Validate(ctx context.Context, accessToken, clientToken
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 {
@@ -209,18 +208,19 @@ func (s *tokenServiceJWT) Validate(ctx context.Context, accessToken, clientToken
} }
// 验证ClientToken如果提供 // 验证ClientToken如果提供
if clientToken != "" && client.ClientToken != clientToken { if clientToken != "" && metadata.ClientToken != clientToken {
return false return false
} }
return true return true
} }
// Refresh 刷新Token使用Version机制无需删除旧Token // Refresh 刷新Token使用Version机制Redis存储
func (s *tokenServiceJWT) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) { func (s *tokenServiceRedis) 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,6 +279,11 @@ func (s *tokenServiceJWT) Refresh(ctx context.Context, accessToken, clientToken,
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
@@ -308,30 +313,27 @@ func (s *tokenServiceJWT) Refresh(ctx context.Context, accessToken, clientToken,
return "", "", fmt.Errorf("生成新AccessToken失败: %w", err) return "", "", fmt.Errorf("生成新AccessToken失败: %w", err)
} }
// 存新Token记录 // 存新Token到Redis
newToken := model.Token{ ttl := expiresAt.Sub(now)
AccessToken: newAccessToken, metadata := &auth.TokenMetadata{
ClientToken: client.ClientToken,
UserID: client.UserID, UserID: client.UserID,
ProfileId: selectedProfileID, ProfileID: selectedProfileID,
ClientUUID: client.UUID,
ClientToken: client.ClientToken,
Version: client.Version, Version: client.Version,
Usable: true, CreatedAt: now.Unix(),
IssueDate: now,
ExpiresAt: &expiresAt,
StaleAt: &staleAt,
} }
err = s.tokenRepo.Create(ctx, &newToken) if err := s.tokenStore.Store(ctx, newAccessToken, metadata, ttl); err != nil {
if err != nil { s.logger.Warn("存储新Token到Redis失败", zap.Error(err))
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失效通过增加Version // Invalidate 使Token失效从Redis删除
func (s *tokenServiceJWT) Invalidate(ctx context.Context, accessToken string) { func (s *tokenServiceRedis) Invalidate(ctx context.Context, accessToken string) {
// 设置超时上下文 // 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel() defer cancel()
@@ -347,7 +349,7 @@ func (s *tokenServiceJWT) Invalidate(ctx context.Context, accessToken string) {
return return
} }
// 查找Client并增加Version // 查找Client并增加Version失效所有旧Token
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))
@@ -362,11 +364,17 @@ func (s *tokenServiceJWT) 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失效 // InvalidateUserTokens 使用户所有Token失效从Redis删除
func (s *tokenServiceJWT) InvalidateUserTokens(ctx context.Context, userID int64) { func (s *tokenServiceRedis) InvalidateUserTokens(ctx context.Context, userID int64) {
// 设置超时上下文 // 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel() defer cancel()
@@ -391,15 +399,20 @@ func (s *tokenServiceJWT) InvalidateUserTokens(ctx context.Context, userID int64
} }
} }
// 从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 *tokenServiceJWT) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) { func (s *tokenServiceRedis) 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 {
// 如果JWT解析失败尝试从数据库查询向后兼容 return "", errors.New("accessToken无效")
return s.tokenRepo.GetUUIDByAccessToken(ctx, accessToken)
} }
if claims.ProfileID != "" { if claims.ProfileID != "" {
@@ -420,11 +433,10 @@ func (s *tokenServiceJWT) GetUUIDByAccessToken(ctx context.Context, accessToken
} }
// GetUserIDByAccessToken 从AccessToken获取UserID通过JWT解析 // GetUserIDByAccessToken 从AccessToken获取UserID通过JWT解析
func (s *tokenServiceJWT) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) { func (s *tokenServiceRedis) 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 {
// 如果JWT解析失败尝试从数据库查询向后兼容 return 0, errors.New("accessToken无效")
return s.tokenRepo.GetUserIDByAccessToken(ctx, accessToken)
} }
// 从Client获取UserID // 从Client获取UserID
@@ -441,44 +453,8 @@ func (s *tokenServiceJWT) GetUserIDByAccessToken(ctx context.Context, accessToke
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不能为空")
} }
@@ -492,24 +468,3 @@ func (s *tokenServiceJWT) validateProfileByUserID(ctx context.Context, userID in
} }
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

@@ -27,7 +27,6 @@ import (
// userService UserService的实现 // userService UserService的实现
type userService struct { type userService struct {
userRepo repository.UserRepository userRepo repository.UserRepository
configRepo repository.SystemConfigRepository
jwtService *auth.JWTService jwtService *auth.JWTService
redis *redis.Client redis *redis.Client
cache *database.CacheManager cache *database.CacheManager
@@ -40,7 +39,6 @@ type userService struct {
// NewUserService 创建UserService实例 // NewUserService 创建UserService实例
func NewUserService( func NewUserService(
userRepo repository.UserRepository, userRepo repository.UserRepository,
configRepo repository.SystemConfigRepository,
jwtService *auth.JWTService, jwtService *auth.JWTService,
redisClient *redis.Client, redisClient *redis.Client,
cacheManager *database.CacheManager, cacheManager *database.CacheManager,
@@ -51,7 +49,6 @@ func NewUserService(
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键 // 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
return &userService{ return &userService{
userRepo: userRepo, userRepo: userRepo,
configRepo: configRepo,
jwtService: jwtService, jwtService: jwtService,
redis: redisClient, redis: redisClient,
cache: cacheManager, cache: cacheManager,
@@ -191,7 +188,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)
}, 5*time.Minute) }, s.cache.Policy.UserTTL)
} }
func (s *userService) GetByEmail(ctx context.Context, email string) (*model.User, error) { func (s *userService) GetByEmail(ctx context.Context, email string) (*model.User, error) {
@@ -199,7 +196,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)
}, 5*time.Minute) }, s.cache.Policy.UserEmailTTL)
} }
func (s *userService) UpdateInfo(ctx context.Context, user *model.User) error { func (s *userService) UpdateInfo(ctx context.Context, user *model.User) error {
@@ -417,39 +414,29 @@ func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData [
} }
func (s *userService) GetMaxProfilesPerUser() int { func (s *userService) GetMaxProfilesPerUser() int {
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user") cfg, err := config.GetConfig()
if err != nil || config == nil { if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
return 5 return 5
} }
var value int return cfg.Site.MaxProfilesPerUser
fmt.Sscanf(config.Value, "%d", &value)
if value <= 0 {
return 5
}
return value
} }
func (s *userService) GetMaxTexturesPerUser() int { func (s *userService) GetMaxTexturesPerUser() int {
config, err := s.configRepo.GetByKey(context.Background(), "max_textures_per_user") cfg, err := config.GetConfig()
if err != nil || config == nil { if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
return 50 return 50
} }
var value int return cfg.Site.MaxTexturesPerUser
fmt.Sscanf(config.Value, "%d", &value)
if value <= 0 {
return 50
}
return value
} }
// 私有辅助方法 // 私有辅助方法
func (s *userService) getDefaultAvatar() string { func (s *userService) getDefaultAvatar() string {
config, err := s.configRepo.GetByKey(context.Background(), "default_avatar") cfg, err := config.GetConfig()
if err != nil || config == nil || config.Value == "" { if err != nil {
return "" return ""
} }
return config.Value return cfg.Site.DefaultAvatar
} }
func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error { func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error {

View File

@@ -12,14 +12,13 @@ import (
func TestUserServiceImpl_Register(t *testing.T) { func TestUserServiceImpl_Register(t *testing.T) {
// 准备依赖 // 准备依赖
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
configRepo := NewMockSystemConfigRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
// 初始化Service // 初始化Service
// 注意redisClient 和 cacheManager 传入 nil因为 Register 方法中没有使用它们 // 注意redisClient 和 storageClient 传入 nil因为 Register 方法中没有使用它们
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -114,7 +113,6 @@ func TestUserServiceImpl_Register(t *testing.T) {
func TestUserServiceImpl_Login(t *testing.T) { func TestUserServiceImpl_Login(t *testing.T) {
// 准备依赖 // 准备依赖
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
configRepo := NewMockSystemConfigRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
@@ -130,7 +128,7 @@ func TestUserServiceImpl_Login(t *testing.T) {
_ = userRepo.Create(context.Background(), testUser) _ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -197,7 +195,6 @@ func TestUserServiceImpl_Login(t *testing.T) {
// TestUserServiceImpl_BasicGetters 测试 GetByID / GetByEmail / UpdateInfo / UpdateAvatar // TestUserServiceImpl_BasicGetters 测试 GetByID / GetByEmail / UpdateInfo / UpdateAvatar
func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) { func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
configRepo := NewMockSystemConfigRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
@@ -211,7 +208,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -246,7 +243,6 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
// TestUserServiceImpl_ChangePassword 测试 ChangePassword // TestUserServiceImpl_ChangePassword 测试 ChangePassword
func TestUserServiceImpl_ChangePassword(t *testing.T) { func TestUserServiceImpl_ChangePassword(t *testing.T) {
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
configRepo := NewMockSystemConfigRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
@@ -259,7 +255,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -282,7 +278,6 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
// TestUserServiceImpl_ResetPassword 测试 ResetPassword // TestUserServiceImpl_ResetPassword 测试 ResetPassword
func TestUserServiceImpl_ResetPassword(t *testing.T) { func TestUserServiceImpl_ResetPassword(t *testing.T) {
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
configRepo := NewMockSystemConfigRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
@@ -294,7 +289,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -312,7 +307,6 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
// TestUserServiceImpl_ChangeEmail 测试 ChangeEmail // TestUserServiceImpl_ChangeEmail 测试 ChangeEmail
func TestUserServiceImpl_ChangeEmail(t *testing.T) { func TestUserServiceImpl_ChangeEmail(t *testing.T) {
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
configRepo := NewMockSystemConfigRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
@@ -322,7 +316,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
_ = userRepo.Create(context.Background(), user2) _ = userRepo.Create(context.Background(), user2)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -340,12 +334,11 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
// TestUserServiceImpl_ValidateAvatarURL 测试 ValidateAvatarURL // TestUserServiceImpl_ValidateAvatarURL 测试 ValidateAvatarURL
func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) { func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
configRepo := NewMockSystemConfigRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -373,30 +366,19 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
} }
// TestUserServiceImpl_MaxLimits 测试 GetMaxProfilesPerUser / GetMaxTexturesPerUser // TestUserServiceImpl_MaxLimits 测试 GetMaxProfilesPerUser / GetMaxTexturesPerUser
// 现在配置从环境变量读取,测试默认值
func TestUserServiceImpl_MaxLimits(t *testing.T) { func TestUserServiceImpl_MaxLimits(t *testing.T) {
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
configRepo := NewMockSystemConfigRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
// 未配置时走默认值 // 未配置时走默认值
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
if got := userService.GetMaxProfilesPerUser(); got != 5 { if got := userService.GetMaxProfilesPerUser(); got != 5 {
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got) t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
} }
if got := userService.GetMaxTexturesPerUser(); got != 50 { if got := userService.GetMaxTexturesPerUser(); got != 50 {
t.Fatalf("GetMaxTexturesPerUser 默认值错误, got=%d", got) t.Fatalf("GetMaxTexturesPerUser 默认值错误, got=%d", got)
} }
// 配置有效值
_ = configRepo.Update(context.Background(), &model.SystemConfig{Key: "max_profiles_per_user", Value: "10"})
_ = configRepo.Update(context.Background(), &model.SystemConfig{Key: "max_textures_per_user", Value: "100"})
if got := userService.GetMaxProfilesPerUser(); got != 10 {
t.Fatalf("GetMaxProfilesPerUser 配置值错误, got=%d", got)
}
if got := userService.GetMaxTexturesPerUser(); got != 100 {
t.Fatalf("GetMaxTexturesPerUser 配置值错误, got=%d", got)
}
} }

View File

@@ -22,7 +22,7 @@ type yggdrasilServiceComposite struct {
serializationService SerializationService serializationService SerializationService
certificateService CertificateService certificateService CertificateService
profileRepo repository.ProfileRepository profileRepo repository.ProfileRepository
tokenRepo repository.TokenRepository tokenService TokenService // 使用TokenService接口不直接依赖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,
tokenRepo: tokenRepo, tokenService: tokenService,
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 {
// 验证Token // 通过TokenService验证Token并获取UUID
token, err := s.tokenRepo.FindByAccessToken(ctx, accessToken) uuid, err := s.tokenService.GetUUIDByAccessToken(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 token.ProfileId != formattedProfile { if uuid != formattedProfile {
return errors.New("selectedProfile与Token不匹配") return errors.New("selectedProfile与Token不匹配")
} }

168
internal/task/runner.go Normal file
View File

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

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

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

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

@@ -173,12 +173,3 @@ type SystemConfigResponse struct {
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"` MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"` MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
} }
// RenderResponse 材质渲染响应
type RenderResponse struct {
URL string `json:"url" example:"https://rustfs.example.com/renders/xxx.png"`
ContentType string `json:"content_type" example:"image/png"`
ETag string `json:"etag,omitempty" example:"abc123def456"`
Size int64 `json:"size" example:"2048"`
LastModified *time.Time `json:"last_modified,omitempty" example:"2025-10-01T12:00:00Z"`
}

124
pkg/auth/casbin.go Normal file
View File

@@ -0,0 +1,124 @@
package auth
import (
"fmt"
"sync"
"github.com/casbin/casbin/v2"
gormadapter "github.com/casbin/gorm-adapter/v3"
"go.uber.org/zap"
"gorm.io/gorm"
)
// CasbinService Casbin权限服务
type CasbinService struct {
enforcer *casbin.Enforcer
logger *zap.Logger
mu sync.RWMutex
}
// NewCasbinService 创建Casbin服务
func NewCasbinService(db *gorm.DB, modelPath string, logger *zap.Logger) (*CasbinService, error) {
// 使用Gorm适配器自动使用casbin_rule表
adapter, err := gormadapter.NewAdapterByDBUseTableName(db, "", "casbin_rule")
if err != nil {
return nil, fmt.Errorf("创建Casbin适配器失败: %w", err)
}
// 创建Enforcer
enforcer, err := casbin.NewEnforcer(modelPath, adapter)
if err != nil {
return nil, fmt.Errorf("创建Casbin执行器失败: %w", err)
}
// 加载策略
if err := enforcer.LoadPolicy(); err != nil {
return nil, fmt.Errorf("加载Casbin策略失败: %w", err)
}
logger.Info("Casbin权限服务初始化成功")
return &CasbinService{
enforcer: enforcer,
logger: logger,
}, nil
}
// Enforce 检查权限
// sub: 主体(用户角色), obj: 资源, act: 操作
func (s *CasbinService) Enforce(sub, obj, act string) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.enforcer.Enforce(sub, obj, act)
}
// CheckPermission 检查用户权限(便捷方法)
func (s *CasbinService) CheckPermission(role, resource, action string) bool {
allowed, err := s.Enforce(role, resource, action)
if err != nil {
s.logger.Error("权限检查失败",
zap.String("role", role),
zap.String("resource", resource),
zap.String("action", action),
zap.Error(err),
)
return false
}
return allowed
}
// AddPolicy 添加策略
func (s *CasbinService) AddPolicy(sub, obj, act string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.enforcer.AddPolicy(sub, obj, act)
}
// RemovePolicy 移除策略
func (s *CasbinService) RemovePolicy(sub, obj, act string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.enforcer.RemovePolicy(sub, obj, act)
}
// AddRoleForUser 为用户添加角色
func (s *CasbinService) AddRoleForUser(user, role string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.enforcer.AddRoleForUser(user, role)
}
// GetRolesForUser 获取用户的角色
func (s *CasbinService) GetRolesForUser(user string) []string {
s.mu.RLock()
defer s.mu.RUnlock()
roles, _ := s.enforcer.GetRolesForUser(user)
return roles
}
// GetPermissionsForRole 获取角色的所有权限
func (s *CasbinService) GetPermissionsForRole(role string) [][]string {
s.mu.RLock()
defer s.mu.RUnlock()
perms, _ := s.enforcer.GetPermissionsForUser(role)
return perms
}
// ReloadPolicy 重新加载策略
func (s *CasbinService) ReloadPolicy() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.enforcer.LoadPolicy()
}
// GetEnforcer 获取原始Enforcer用于高级操作
func (s *CasbinService) GetEnforcer() *casbin.Enforcer {
return s.enforcer
}

320
pkg/auth/token_redis.go Normal file
View File

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

@@ -14,6 +14,7 @@ import (
// Config 应用配置结构体 // Config 应用配置结构体
type Config struct { type Config struct {
Environment string `mapstructure:"environment"` Environment string `mapstructure:"environment"`
Site SiteConfig `mapstructure:"site"`
Server ServerConfig `mapstructure:"server"` Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"` Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"` Redis RedisConfig `mapstructure:"redis"`
@@ -25,6 +26,18 @@ type Config struct {
Security SecurityConfig `mapstructure:"security"` Security SecurityConfig `mapstructure:"security"`
} }
// SiteConfig 站点配置
type SiteConfig struct {
Name string `mapstructure:"name"`
Description string `mapstructure:"description"`
RegistrationEnabled bool `mapstructure:"registration_enabled"`
DefaultAvatar string `mapstructure:"default_avatar"`
MaxTexturesPerUser int `mapstructure:"max_textures_per_user"`
MaxProfilesPerUser int `mapstructure:"max_profiles_per_user"`
CheckinReward int `mapstructure:"checkin_reward"`
TextureDownloadReward int `mapstructure:"texture_download_reward"`
}
// ServerConfig 服务器配置 // ServerConfig 服务器配置
type ServerConfig struct { type ServerConfig struct {
Port string `mapstructure:"port"` Port string `mapstructure:"port"`
@@ -201,6 +214,16 @@ func setDefaults() {
// 安全默认配置 // 安全默认配置
viper.SetDefault("security.allowed_origins", []string{"*"}) viper.SetDefault("security.allowed_origins", []string{"*"})
viper.SetDefault("security.allowed_domains", []string{"localhost", "127.0.0.1"}) viper.SetDefault("security.allowed_domains", []string{"localhost", "127.0.0.1"})
// 站点默认配置
viper.SetDefault("site.name", "CarrotSkin")
viper.SetDefault("site.description", "一个优秀的Minecraft皮肤站")
viper.SetDefault("site.registration_enabled", true)
viper.SetDefault("site.default_avatar", "")
viper.SetDefault("site.max_textures_per_user", 50)
viper.SetDefault("site.max_profiles_per_user", 5)
viper.SetDefault("site.checkin_reward", 10)
viper.SetDefault("site.texture_download_reward", 1)
} }
// setupEnvMappings 设置环境变量映射 // setupEnvMappings 设置环境变量映射
@@ -262,6 +285,16 @@ func setupEnvMappings() {
viper.BindEnv("email.username", "EMAIL_USERNAME") viper.BindEnv("email.username", "EMAIL_USERNAME")
viper.BindEnv("email.password", "EMAIL_PASSWORD") viper.BindEnv("email.password", "EMAIL_PASSWORD")
viper.BindEnv("email.from_name", "EMAIL_FROM_NAME") viper.BindEnv("email.from_name", "EMAIL_FROM_NAME")
// 站点配置
viper.BindEnv("site.name", "SITE_NAME")
viper.BindEnv("site.description", "SITE_DESCRIPTION")
viper.BindEnv("site.registration_enabled", "REGISTRATION_ENABLED")
viper.BindEnv("site.default_avatar", "DEFAULT_AVATAR")
viper.BindEnv("site.max_textures_per_user", "MAX_TEXTURES_PER_USER")
viper.BindEnv("site.max_profiles_per_user", "MAX_PROFILES_PER_USER")
viper.BindEnv("site.checkin_reward", "CHECKIN_REWARD")
viper.BindEnv("site.texture_download_reward", "TEXTURE_DOWNLOAD_REWARD")
} }
// overrideFromEnv 从环境变量中覆盖配置 // overrideFromEnv 从环境变量中覆盖配置

View File

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

@@ -14,12 +14,24 @@ 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 创建缓存管理器
@@ -31,9 +43,33 @@ 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,
} }
} }
@@ -56,6 +92,14 @@ 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 {
@@ -75,6 +119,13 @@ 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 {
@@ -187,11 +238,7 @@ func Cached[T any](
} }
// 设置缓存(异步,不阻塞) // 设置缓存(异步,不阻塞)
go func() { cache.SetAsync(context.Background(), key, data, expiration...)
cacheCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = cache.Set(cacheCtx, key, data, expiration...)
}()
return data, nil return data, nil
} }
@@ -217,11 +264,7 @@ func CachedList[T any](
} }
// 设置缓存(异步,不阻塞) // 设置缓存(异步,不阻塞)
go func() { cache.SetAsync(context.Background(), key, data, expiration...)
cacheCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = cache.Set(cacheCtx, key, data, expiration...)
}()
return data, nil return data, nil
} }
@@ -306,9 +349,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)
} }
// TextureRender 构建材质渲染缓存键 // TextureListPattern 构建材质列表缓存键模式(用于批量失效)
func (b *CacheKeyBuilder) TextureRender(textureID int64, renderType string, size int) string { func (b *CacheKeyBuilder) TextureListPattern(userID int64) string {
return fmt.Sprintf("%stexture:render:%d:%s:%d", b.prefix, textureID, renderType, size) return fmt.Sprintf("%stexture:user:%d:*", b.prefix, userID)
} }
// Token 构建令牌缓存键 // Token 构建令牌缓存键

184
pkg/database/cache_test.go Normal file
View File

@@ -0,0 +1,184 @@
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,15 +75,11 @@ 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
&model.Yggdrasil{}, &model.Yggdrasil{},
// 系统配置表
&model.SystemConfig{},
// 审计日志表 // 审计日志表
&model.AuditLog{}, &model.AuditLog{},

View File

@@ -0,0 +1,24 @@
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,6 +9,7 @@ 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("未初始化时应该返回错误")
@@ -22,6 +23,7 @@ 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")
@@ -33,6 +35,7 @@ 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",
@@ -53,7 +56,7 @@ func TestInit_Database(t *testing.T) {
// 注意:实际连接可能失败,这是可以接受的 // 注意:实际连接可能失败,这是可以接受的
err := Init(cfg, logger) err := Init(cfg, logger)
if err != nil { if err != nil {
t.Logf("Init() 返回错误(可能正常,如果数据库未运行): %v", err) t.Skipf("数据库未运行,跳过连接测试: %v", err)
} }
} }
@@ -82,4 +85,3 @@ func TestClose_NotInitialized(t *testing.T) {
t.Errorf("Close() 在未初始化时应该返回nil实际返回: %v", err) t.Errorf("Close() 在未初始化时应该返回nil实际返回: %v", err)
} }
} }

View File

@@ -12,36 +12,41 @@ import (
const ( const (
defaultAdminUsername = "admin" defaultAdminUsername = "admin"
defaultAdminEmail = "admin@example.com" defaultAdminEmail = "admin@example.com"
defaultAdminPassword = "admin123456" // 首次登录后请立即修改 defaultAdminPassword = "admin123456" // 首次登录后请立即修改,部署到生产环境后删除
) )
// defaultSystemConfigs 默认系统配置
var defaultSystemConfigs = []model.SystemConfig{
{Key: "site_name", Value: "CarrotSkin", Description: "网站名称", Type: model.ConfigTypeString, IsPublic: true},
{Key: "site_description", Value: "一个优秀的Minecraft皮肤站", Description: "网站描述", Type: model.ConfigTypeString, IsPublic: true},
{Key: "registration_enabled", Value: "true", Description: "是否允许用户注册", Type: model.ConfigTypeBoolean, IsPublic: true},
{Key: "checkin_reward", Value: "10", Description: "签到奖励积分", Type: model.ConfigTypeInteger, IsPublic: true},
{Key: "texture_download_reward", Value: "1", Description: "材质被下载奖励积分", Type: model.ConfigTypeInteger, IsPublic: false},
{Key: "max_textures_per_user", Value: "50", Description: "每个用户最大材质数量", Type: model.ConfigTypeInteger, IsPublic: false},
{Key: "max_profiles_per_user", Value: "5", Description: "每个用户最大角色数量", Type: model.ConfigTypeInteger, IsPublic: false},
{Key: "default_avatar", Value: "", Description: "默认头像URL", Type: model.ConfigTypeString, IsPublic: true},
}
// defaultCasbinRules 默认Casbin权限规则 // defaultCasbinRules 默认Casbin权限规则
// 规则格式: {PType: "p", V0: "角色", V1: "资源", V2: "操作"}
// PType "p" 表示策略规则,"g" 表示角色继承
var defaultCasbinRules = []model.CasbinRule{ var defaultCasbinRules = []model.CasbinRule{
// 管理员拥有所有权限 // ==================== 管理员权限 ====================
// 管理员拥有所有权限(通配符)
{PType: "p", V0: "admin", V1: "*", V2: "*"}, {PType: "p", V0: "admin", V1: "*", V2: "*"},
// 普通用户权限
{PType: "p", V0: "user", V1: "texture", V2: "create"}, // ==================== 普通用户权限 ====================
{PType: "p", V0: "user", V1: "texture", V2: "read"}, // --- 用户资源 (user) ---
{PType: "p", V0: "user", V1: "texture", V2: "update_own"}, {PType: "p", V0: "user", V1: "user", V2: "read_own"}, // 查看自己的信息
{PType: "p", V0: "user", V1: "texture", V2: "delete_own"}, {PType: "p", V0: "user", V1: "user", V2: "update_own"}, // 更新自己的信息
{PType: "p", V0: "user", V1: "profile", V2: "create"},
{PType: "p", V0: "user", V1: "profile", V2: "read"}, // --- 材质资源 (texture) ---
{PType: "p", V0: "user", V1: "profile", V2: "update_own"}, {PType: "p", V0: "user", V1: "texture", V2: "read"}, // 查看材质(公开)
{PType: "p", V0: "user", V1: "profile", V2: "delete_own"}, {PType: "p", V0: "user", V1: "texture", V2: "create"}, // 上传材质
{PType: "p", V0: "user", V1: "user", V2: "update_own"}, {PType: "p", V0: "user", V1: "texture", V2: "update_own"}, // 更新自己的材质
// 角色继承admin 继承 user 的所有权限 {PType: "p", V0: "user", V1: "texture", V2: "delete_own"}, // 删除自己的材质
{PType: "p", V0: "user", V1: "texture", V2: "favorite"}, // 收藏材质
// --- 档案资源 (profile) ---
{PType: "p", V0: "user", V1: "profile", V2: "read"}, // 查看档案(公开)
{PType: "p", V0: "user", V1: "profile", V2: "create"}, // 创建档案
{PType: "p", V0: "user", V1: "profile", V2: "update_own"}, // 更新自己的档案
{PType: "p", V0: "user", V1: "profile", V2: "delete_own"}, // 删除自己的档案
// --- Yggdrasil资源 (yggdrasil) ---
{PType: "p", V0: "user", V1: "yggdrasil", V2: "auth"}, // Yggdrasil认证
{PType: "p", V0: "user", V1: "yggdrasil", V2: "reset_password"}, // 重置Yggdrasil密码
// ==================== 角色继承 ====================
// admin 继承 user 的所有权限
{PType: "g", V0: "admin", V1: "user"}, {PType: "g", V0: "admin", V1: "user"},
} }
@@ -59,11 +64,6 @@ func Seed(logger *zap.Logger) error {
return err return err
} }
// 初始化系统配置
if err := seedSystemConfigs(db, logger); err != nil {
return err
}
// 初始化Casbin权限规则 // 初始化Casbin权限规则
if err := seedCasbinRules(db, logger); err != nil { if err := seedCasbinRules(db, logger); err != nil {
return err return err
@@ -119,23 +119,6 @@ func seedAdminUser(db *gorm.DB, logger *zap.Logger) error {
return nil return nil
} }
// seedSystemConfigs 初始化系统配置
func seedSystemConfigs(db *gorm.DB, logger *zap.Logger) error {
for _, config := range defaultSystemConfigs {
// 使用 FirstOrCreate 避免重复插入
var existing model.SystemConfig
result := db.Where("key = ?", config.Key).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
if err := db.Create(&config).Error; err != nil {
logger.Error("创建系统配置失败", zap.String("key", config.Key), zap.Error(err))
return err
}
logger.Info("创建系统配置", zap.String("key", config.Key))
}
}
return nil
}
// seedCasbinRules 初始化Casbin权限规则 // seedCasbinRules 初始化Casbin权限规则
func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error { func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error {
for _, rule := range defaultCasbinRules { for _, rule := range defaultCasbinRules {
@@ -153,4 +136,3 @@ func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error {
} }
return nil return nil
} }

56
pkg/email/email_test.go Normal file
View File

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

@@ -2,13 +2,20 @@ 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("未初始化时应该返回错误")
@@ -22,6 +29,7 @@ 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")
@@ -33,6 +41,7 @@ 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",
@@ -58,4 +67,3 @@ func TestInit_Email(t *testing.T) {
t.Error("GetService() 返回的服务不应为nil") t.Error("GetService() 返回的服务不应为nil")
} }
} }

View File

@@ -3,8 +3,11 @@ 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"
) )
@@ -15,19 +18,69 @@ 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() {
clientInstance, initError = New(cfg, logger) // 尝试连接真实Redis
if initError != nil { clientInstance, err = New(cfg, logger)
if err != nil {
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 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 {
@@ -45,13 +98,21 @@ 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
}

71
pkg/storage/minio_test.go Normal file
View File

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

View File

@@ -1,84 +0,0 @@
#!/bin/bash
# CarrotSkin 环境变量检查脚本
echo "🔍 检查 CarrotSkin 环境变量配置..."
# 必需的环境变量列表
REQUIRED_VARS=(
"DATABASE_HOST"
"DATABASE_USERNAME"
"DATABASE_PASSWORD"
"DATABASE_NAME"
"REDIS_HOST"
"RUSTFS_ENDPOINT"
"RUSTFS_ACCESS_KEY"
"RUSTFS_SECRET_KEY"
"RUSTFS_BUCKET_TEXTURES"
"RUSTFS_BUCKET_AVATARS"
"JWT_SECRET"
)
# 检查.env文件是否存在
if [ ! -f ".env" ]; then
echo "❌ .env 文件不存在"
echo "💡 请复制 .env.example 为 .env 并配置相关变量"
exit 1
fi
echo "✅ .env 文件存在"
# 加载.env文件
set -a
source .env 2>/dev/null
set +a
# 检查必需的环境变量
missing_vars=()
for var in "${REQUIRED_VARS[@]}"; do
if [ -z "${!var}" ]; then
missing_vars+=("$var")
fi
done
if [ ${#missing_vars[@]} -gt 0 ]; then
echo "❌ 缺少以下必需的环境变量:"
for var in "${missing_vars[@]}"; do
echo " - $var"
done
echo ""
echo "💡 请在 .env 文件中设置这些变量"
exit 1
fi
echo "✅ 所有必需的环境变量都已设置"
# 检查关键配置的合理性
echo ""
echo "📋 当前配置概览:"
echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:${DATABASE_PORT:-5432}/$DATABASE_NAME"
echo " Redis: $REDIS_HOST:${REDIS_PORT:-6379}"
echo " RustFS: $RUSTFS_ENDPOINT"
echo " 存储桶: $RUSTFS_BUCKET_TEXTURES, $RUSTFS_BUCKET_AVATARS"
echo " JWT密钥长度: ${#JWT_SECRET} 字符"
# 检查JWT密钥长度
if [ ${#JWT_SECRET} -lt 32 ]; then
echo "⚠️ JWT密钥过短建议使用至少32字符的随机字符串"
fi
# 检查默认密码
if [ "$JWT_SECRET" = "your-jwt-secret-key-change-this-in-production" ]; then
echo "⚠️ 使用的是默认JWT密钥生产环境中请更改"
fi
if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ] || [ "$DATABASE_PASSWORD" = "carrotskin123" ]; then
echo "⚠️ 使用的是默认数据库密码,生产环境中请更改"
fi
if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ] || [ "$RUSTFS_ACCESS_KEY" = "rustfsadmin" ]; then
echo "⚠️ 使用的是默认RustFS凭证生产环境中请更改"
fi
echo ""
echo "🎉 环境变量检查完成!"

View File

@@ -257,10 +257,181 @@ curl -X GET http://localhost:8080/api/v1/profile/{profile_info['profile_uuid']}
return output return output
def set_user_role(admin_token, user_id, role):
"""设置用户角色"""
headers = {
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json"
}
try:
response = requests.put(
f"{BASE_URL}/admin/users/role",
json={"user_id": user_id, "role": role},
headers=headers,
timeout=10
)
response.raise_for_status()
result = response.json()
if result.get("code") == 200:
print_success(f"用户角色设置为: {role}")
return True
else:
print_error(f"设置角色失败: {result.get('message', '未知错误')}")
return False
except requests.exceptions.RequestException as e:
print_error(f"设置角色失败: {str(e)}")
return False
def login_user(username, password):
"""登录用户"""
try:
response = requests.post(
f"{BASE_URL}/auth/login",
json={"username": username, "password": password},
headers={"Content-Type": "application/json"},
timeout=10
)
response.raise_for_status()
result = response.json()
if result.get("code") == 200:
return result["data"]["token"]
return None
except requests.exceptions.RequestException:
return None
def create_admin_user():
"""创建管理员用户"""
print_step("创建管理员用户")
random_num = random.randint(10000, 99999)
username = f"admin{random_num}"
email = f"admin{random_num}@example.com"
login_password = "admin123456"
verification_code = "123456"
print_info(f"用户名: {username}")
print_info(f"邮箱: {email}")
print_info(f"密码: {login_password}")
register_data = {
"username": username,
"email": email,
"password": login_password,
"verification_code": verification_code
}
try:
response = requests.post(
f"{BASE_URL}/auth/register",
json=register_data,
headers={"Content-Type": "application/json"},
timeout=10
)
response.raise_for_status()
result = response.json()
if result.get("code") == 200:
jwt_token = result["data"]["token"]
user_id = result["data"]["user_info"]["id"]
print_success("用户注册成功!")
print_info(f"用户ID: {user_id}")
# 使用默认管理员账户提升权限
print_info("尝试使用默认管理员账户提升权限...")
default_admin_token = login_user("admin", "admin123456")
if default_admin_token:
if set_user_role(default_admin_token, user_id, "admin"):
print_success("管理员权限设置成功!")
return {
"username": username,
"email": email,
"password": login_password,
"jwt_token": jwt_token,
"user_id": user_id,
"role": "admin"
}
print_error("无法提升权限,请手动设置")
return {
"username": username,
"email": email,
"password": login_password,
"jwt_token": jwt_token,
"user_id": user_id,
"role": "user"
}
else:
print_error(f"注册失败: {result.get('message', '未知错误')}")
return None
except requests.exceptions.RequestException as e:
print_error(f"注册失败: {str(e)}")
return None
def generate_admin_output(admin_info):
"""生成管理员账户输出信息"""
output = f"""========================================
CarrotSkin 管理员账户信息
========================================
=== 账户信息 ===
用户名: {admin_info['username']}
邮箱: {admin_info['email']}
密码: {admin_info['password']}
用户ID: {admin_info['user_id']}
角色: {admin_info['role']}
=== JWT Token (API认证) ===
Token: {admin_info['jwt_token']}
=== 管理员API示例 ===
# 1. 获取用户列表
curl -X GET "{BASE_URL}/admin/users" \\
-H "Authorization: Bearer {admin_info['jwt_token']}"
# 2. 设置用户角色为管理员
curl -X PUT "{BASE_URL}/admin/users/role" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer {admin_info['jwt_token']}" \\
-d '{{"user_id": 1, "role": "admin"}}'
# 3. 获取材质列表(审核)
curl -X GET "{BASE_URL}/admin/textures" \\
-H "Authorization: Bearer {admin_info['jwt_token']}"
# 4. 删除材质
curl -X DELETE "{BASE_URL}/admin/textures/1" \\
-H "Authorization: Bearer {admin_info['jwt_token']}"
========================================
"""
return output
def main(): def main():
"""主函数""" """主函数"""
print_header("CarrotSkin 测试账户生成器") print_header("CarrotSkin 测试账户生成器")
# 选择模式
print("请选择操作:")
print(" 1. 创建普通测试用户")
print(" 2. 创建管理员用户")
print(" 3. 创建两者")
choice = input("\n请输入选项 (1/2/3) [默认: 1]: ").strip() or "1"
if choice in ["1", "3"]:
# 创建普通用户
print_header("创建普通测试用户")
# 步骤1: 注册用户 # 步骤1: 注册用户
user_info = register_user() user_info = register_user()
@@ -270,9 +441,8 @@ def main():
# 步骤3: 重置Yggdrasil密码 # 步骤3: 重置Yggdrasil密码
yggdrasil_password = reset_yggdrasil_password(user_info["jwt_token"]) yggdrasil_password = reset_yggdrasil_password(user_info["jwt_token"])
# 步骤4: 输出所有信息 # 输出信息
print_header("测试账户信息汇总") print_header("普通用户信息汇总")
output = generate_output(user_info, profile_info, yggdrasil_password) output = generate_output(user_info, profile_info, yggdrasil_password)
print(output) print(output)
@@ -285,6 +455,25 @@ def main():
except Exception as e: except Exception as e:
print_error(f"保存文件失败: {str(e)}") print_error(f"保存文件失败: {str(e)}")
if choice in ["2", "3"]:
# 创建管理员用户
print_header("创建管理员用户")
admin_info = create_admin_user()
if admin_info:
print_header("管理员账户信息汇总")
admin_output = generate_admin_output(admin_info)
print(admin_output)
# 保存到文件
admin_output_file = f"admin_account_{admin_info['username']}.txt"
try:
with open(admin_output_file, "w", encoding="utf-8") as f:
f.write(admin_output)
print_success(f"管理员信息已保存到文件: {admin_output_file}")
except Exception as e:
print_error(f"保存文件失败: {str(e)}")
print_header("测试完成!") print_header("测试完成!")