Compare commits
4 Commits
feature/re
...
Server-sid
| Author | SHA1 | Date | |
|---|---|---|---|
| 399e6f096f | |||
| 63ca7eff0d | |||
| aa75691c49 | |||
|
|
a51535a465 |
@@ -1,18 +1,16 @@
|
|||||||
# ==================== CarrotSkin Docker 环境配置示例 ====================
|
# ==================== CarrotSkin Docker 环境配置示例 ====================
|
||||||
# 复制此文件为 .env 后修改配置值
|
# 复制此文件为 .env 后修改配置值
|
||||||
|
# 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致
|
||||||
|
|
||||||
# ==================== 服务配置 ====================
|
# ==================== 服务配置 ====================
|
||||||
# 应用端口
|
# 应用对外端口
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
# 运行模式: debug, release, test
|
# 运行模式: debug, release, test
|
||||||
SERVER_MODE=release
|
SERVER_MODE=release
|
||||||
# API 根路径 (用于反向代理,如 /api)
|
|
||||||
SERVER_BASE_PATH=
|
|
||||||
# 公开访问地址 (用于生成回调URL、邮件链接等)
|
|
||||||
PUBLIC_URL=http://localhost:8080
|
|
||||||
|
|
||||||
# ==================== 数据库配置 ====================
|
# ==================== 数据库配置 ====================
|
||||||
DB_PASSWORD=carrotskin123
|
# 数据库密码,生产环境务必修改
|
||||||
|
DATABASE_PASSWORD=carrotskin123
|
||||||
|
|
||||||
# ==================== Redis 配置 ====================
|
# ==================== Redis 配置 ====================
|
||||||
# 留空表示不设置密码
|
# 留空表示不设置密码
|
||||||
@@ -25,23 +23,26 @@ JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
|||||||
# ==================== 存储配置 (RustFS S3兼容) ====================
|
# ==================== 存储配置 (RustFS S3兼容) ====================
|
||||||
# 内部访问地址 (容器间通信)
|
# 内部访问地址 (容器间通信)
|
||||||
RUSTFS_ENDPOINT=rustfs:9000
|
RUSTFS_ENDPOINT=rustfs:9000
|
||||||
|
# 公开访问地址 (用于生成文件URL,供外部浏览器访问)
|
||||||
|
# 示例: 直接访问 http://localhost:9000 或反向代理 https://example.com/storage
|
||||||
|
RUSTFS_PUBLIC_URL=http://localhost:9000
|
||||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||||
RUSTFS_SECRET_KEY=rustfsadmin123
|
RUSTFS_SECRET_KEY=rustfsadmin123
|
||||||
RUSTFS_USE_SSL=false
|
RUSTFS_USE_SSL=false
|
||||||
|
|
||||||
# 存储桶配置
|
# 存储桶配置
|
||||||
RUSTFS_BUCKET_TEXTURES=carrotskin
|
RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
|
||||||
RUSTFS_BUCKET_AVATARS=carrotskin
|
RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
|
||||||
|
|
||||||
# 公开访问地址 (用于生成文件URL,供外部浏览器访问)
|
# ==================== 安全配置 ====================
|
||||||
# 示例:
|
# CORS 允许的来源,多个用逗号分隔
|
||||||
# 直接访问: http://localhost:9000
|
SECURITY_ALLOWED_ORIGINS=*
|
||||||
# 反向代理: https://example.com/storage
|
# 允许的头像/材质URL域名,多个用逗号分隔
|
||||||
RUSTFS_PUBLIC_URL=http://localhost:9000
|
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
|
||||||
|
|
||||||
# ==================== 邮件配置 (可选) ====================
|
# ==================== 邮件配置 ====================
|
||||||
SMTP_HOST=
|
EMAIL_ENABLED=false
|
||||||
SMTP_PORT=587
|
EMAIL_SMTP_HOST=
|
||||||
SMTP_USER=
|
EMAIL_SMTP_PORT=587
|
||||||
SMTP_PASSWORD=
|
EMAIL_USERNAME=
|
||||||
SMTP_FROM=
|
EMAIL_PASSWORD=
|
||||||
|
EMAIL_FROM_NAME=CarrotSkin
|
||||||
|
|||||||
21
.env.example
21
.env.example
@@ -23,6 +23,7 @@ DATABASE_TIMEZONE=Asia/Shanghai
|
|||||||
DATABASE_MAX_IDLE_CONNS=10
|
DATABASE_MAX_IDLE_CONNS=10
|
||||||
DATABASE_MAX_OPEN_CONNS=100
|
DATABASE_MAX_OPEN_CONNS=100
|
||||||
DATABASE_CONN_MAX_LIFETIME=1h
|
DATABASE_CONN_MAX_LIFETIME=1h
|
||||||
|
DATABASE_CONN_MAX_IDLE_TIME=10m
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Redis配置
|
# Redis配置
|
||||||
@@ -37,6 +38,7 @@ REDIS_POOL_SIZE=10
|
|||||||
# RustFS对象存储配置 (S3兼容)
|
# RustFS对象存储配置 (S3兼容)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
RUSTFS_ENDPOINT=127.0.0.1:9000
|
RUSTFS_ENDPOINT=127.0.0.1:9000
|
||||||
|
RUSTFS_PUBLIC_URL=http://127.0.0.1:9000
|
||||||
RUSTFS_ACCESS_KEY=your_access_key
|
RUSTFS_ACCESS_KEY=your_access_key
|
||||||
RUSTFS_SECRET_KEY=your_secret_key
|
RUSTFS_SECRET_KEY=your_secret_key
|
||||||
RUSTFS_USE_SSL=false
|
RUSTFS_USE_SSL=false
|
||||||
@@ -55,26 +57,17 @@ JWT_EXPIRE_HOURS=168
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FORMAT=json
|
LOG_FORMAT=json
|
||||||
LOG_OUTPUT=logs/app.log
|
LOG_OUTPUT=logs/app.log
|
||||||
LOG_MAX_SIZE=100
|
|
||||||
LOG_MAX_BACKUPS=3
|
|
||||||
LOG_MAX_AGE=28
|
|
||||||
LOG_COMPRESS=true
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 文件上传配置
|
|
||||||
# =============================================================================
|
|
||||||
UPLOAD_MAX_SIZE=10485760
|
|
||||||
UPLOAD_TEXTURE_MAX_SIZE=2097152
|
|
||||||
UPLOAD_AVATAR_MAX_SIZE=1048576
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 安全配置
|
# 安全配置
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
MAX_LOGIN_ATTEMPTS=5
|
# CORS 允许的来源,多个用逗号分隔
|
||||||
LOGIN_LOCK_DURATION=30m
|
SECURITY_ALLOWED_ORIGINS=*
|
||||||
|
# 允许的头像/材质URL域名,多个用逗号分隔
|
||||||
|
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 邮件配置(可选)
|
# 邮件配置
|
||||||
# 腾讯企业邮箱SSL配置示例:smtp.exmail.qq.com, 端口465
|
# 腾讯企业邮箱SSL配置示例:smtp.exmail.qq.com, 端口465
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
EMAIL_ENABLED=false
|
EMAIL_ENABLED=false
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,8 +23,8 @@ dist/
|
|||||||
build/
|
build/
|
||||||
|
|
||||||
# Compiled binaries
|
# Compiled binaries
|
||||||
/server
|
|
||||||
server.exe
|
server.exe
|
||||||
|
main.exe
|
||||||
|
|
||||||
# IDE files
|
# IDE files
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -108,3 +108,5 @@ local/
|
|||||||
dev/
|
dev/
|
||||||
service_coverage
|
service_coverage
|
||||||
.gitignore
|
.gitignore
|
||||||
|
docs/
|
||||||
|
blessing skin材质渲染示例/
|
||||||
|
|||||||
74
Dockerfile
Normal file
74
Dockerfile
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# ==================== 构建阶段 ====================
|
||||||
|
FROM golang:latest AS builder
|
||||||
|
|
||||||
|
# 安装构建依赖
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# 配置 Go 代理并下载依赖
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
|
-ldflags="-w -s -X main.Version=$(git describe --tags --always --dirty 2>/dev/null || echo 'dev')" \
|
||||||
|
-o server ./cmd/server
|
||||||
|
|
||||||
|
# ==================== 运行阶段 ====================
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
# 安装运行时依赖
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 创建非 root 用户
|
||||||
|
RUN adduser -D -g '' appuser
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 从构建阶段复制二进制文件
|
||||||
|
COPY --from=builder /build/server .
|
||||||
|
|
||||||
|
# 复制配置文件目录结构
|
||||||
|
COPY --from=builder /build/configs ./configs
|
||||||
|
|
||||||
|
# 设置文件权限
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# 切换到非 root 用户
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
ENTRYPOINT ["./server"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
// @title CarrotSkin API
|
||||||
|
// @version 1.0
|
||||||
|
// @description Minecraft皮肤站后端API
|
||||||
|
// @host localhost:8080
|
||||||
|
// @BasePath /api/v1
|
||||||
|
// @securityDefinitions.apikey BearerAuth
|
||||||
|
// @in header
|
||||||
|
// @name Authorization
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,7 +21,6 @@ import (
|
|||||||
"carrotskin/internal/container"
|
"carrotskin/internal/container"
|
||||||
"carrotskin/internal/handler"
|
"carrotskin/internal/handler"
|
||||||
"carrotskin/internal/middleware"
|
"carrotskin/internal/middleware"
|
||||||
"carrotskin/internal/task"
|
|
||||||
"carrotskin/pkg/auth"
|
"carrotskin/pkg/auth"
|
||||||
"carrotskin/pkg/config"
|
"carrotskin/pkg/config"
|
||||||
"carrotskin/pkg/database"
|
"carrotskin/pkg/database"
|
||||||
@@ -23,6 +31,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
_ "carrotskin/docs" // Swagger docs
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -60,18 +70,11 @@ func main() {
|
|||||||
loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err))
|
loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化Redis(开发/测试环境失败时会自动回退到miniredis)
|
// 初始化Redis
|
||||||
if err := redis.Init(cfg.Redis, loggerInstance); err != nil {
|
if err := redis.Init(cfg.Redis, loggerInstance); err != nil {
|
||||||
loggerInstance.Fatal("Redis初始化失败", zap.Error(err))
|
loggerInstance.Fatal("Redis连接失败", zap.Error(err))
|
||||||
}
|
|
||||||
defer redis.Close()
|
|
||||||
|
|
||||||
// 记录Redis模式
|
|
||||||
if redis.IsUsingMiniRedis() {
|
|
||||||
loggerInstance.Info("使用miniredis进行开发/测试")
|
|
||||||
} else {
|
|
||||||
loggerInstance.Info("使用生产Redis")
|
|
||||||
}
|
}
|
||||||
|
defer redis.MustGetClient().Close()
|
||||||
|
|
||||||
// 初始化对象存储 (RustFS - S3兼容)
|
// 初始化对象存储 (RustFS - S3兼容)
|
||||||
var storageClient *storage.StorageClient
|
var storageClient *storage.StorageClient
|
||||||
@@ -118,13 +121,6 @@ func main() {
|
|||||||
// 使用依赖注入方式注册路由
|
// 使用依赖注入方式注册路由
|
||||||
handler.RegisterRoutesWithDI(router, c)
|
handler.RegisterRoutesWithDI(router, c)
|
||||||
|
|
||||||
// 启动后台任务(Token已迁移到Redis,不再需要清理任务)
|
|
||||||
// 如需使用数据库Token存储,可以恢复TokenCleanupTask
|
|
||||||
taskRunner := task.NewRunner(loggerInstance)
|
|
||||||
taskCtx, taskCancel := context.WithCancel(context.Background())
|
|
||||||
defer taskCancel()
|
|
||||||
taskRunner.Start(taskCtx)
|
|
||||||
|
|
||||||
// 创建HTTP服务器
|
// 创建HTTP服务器
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Server.Port,
|
Addr: cfg.Server.Port,
|
||||||
@@ -147,10 +143,6 @@ func main() {
|
|||||||
<-quit
|
<-quit
|
||||||
loggerInstance.Info("正在关闭服务器...")
|
loggerInstance.Info("正在关闭服务器...")
|
||||||
|
|
||||||
// 停止后台任务
|
|
||||||
taskCancel()
|
|
||||||
taskRunner.Wait()
|
|
||||||
|
|
||||||
// 设置关闭超时
|
// 设置关闭超时
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -13,40 +13,43 @@ services:
|
|||||||
- "${APP_PORT:-8080}:8080"
|
- "${APP_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
- SERVER_PORT=8080
|
- SERVER_PORT=:8080
|
||||||
- SERVER_MODE=${SERVER_MODE:-release}
|
- SERVER_MODE=${SERVER_MODE:-release}
|
||||||
- SERVER_BASE_PATH=${SERVER_BASE_PATH:-}
|
|
||||||
# 公开访问地址 (用于生成回调URL、邮件链接等)
|
|
||||||
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:8080}
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
- DB_HOST=postgres
|
- DATABASE_DRIVER=postgres
|
||||||
- DB_PORT=5432
|
- DATABASE_HOST=postgres
|
||||||
- DB_USER=carrotskin
|
- DATABASE_PORT=5432
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-carrotskin123}
|
- DATABASE_USERNAME=carrotskin
|
||||||
- DB_NAME=carrotskin
|
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
|
||||||
- DB_SSLMODE=disable
|
- DATABASE_NAME=carrotskin
|
||||||
|
- DATABASE_SSL_MODE=disable
|
||||||
|
- DATABASE_TIMEZONE=Asia/Shanghai
|
||||||
# Redis 配置
|
# Redis 配置
|
||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
- REDIS_PORT=6379
|
- REDIS_PORT=6379
|
||||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||||
- REDIS_DB=0
|
- REDIS_DATABASE=0
|
||||||
# JWT 配置
|
# JWT 配置
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||||
- JWT_EXPIRE_HOURS=24
|
- JWT_EXPIRE_HOURS=168
|
||||||
# 存储配置 (RustFS S3兼容)
|
# 存储配置 (RustFS S3兼容)
|
||||||
- RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000}
|
- RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000}
|
||||||
- RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000}
|
- RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000}
|
||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
|
||||||
- RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false}
|
- RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false}
|
||||||
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrotskin}
|
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
|
||||||
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrotskin}
|
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
|
||||||
# 邮件配置 (可选)
|
# 安全配置
|
||||||
- SMTP_HOST=${SMTP_HOST:-}
|
- SECURITY_ALLOWED_ORIGINS=${SECURITY_ALLOWED_ORIGINS:-*}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SECURITY_ALLOWED_DOMAINS=${SECURITY_ALLOWED_DOMAINS:-localhost,127.0.0.1}
|
||||||
- SMTP_USER=${SMTP_USER:-}
|
# 邮件配置
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
- EMAIL_ENABLED=${EMAIL_ENABLED:-false}
|
||||||
- SMTP_FROM=${SMTP_FROM:-}
|
- EMAIL_SMTP_HOST=${EMAIL_SMTP_HOST:-}
|
||||||
|
- EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-587}
|
||||||
|
- EMAIL_USERNAME=${EMAIL_USERNAME:-}
|
||||||
|
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
|
||||||
|
- EMAIL_FROM_NAME=${EMAIL_FROM_NAME:-CarrotSkin}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -68,7 +71,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=carrotskin
|
- POSTGRES_USER=carrotskin
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD:-carrotskin123}
|
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
|
||||||
- POSTGRES_DB=carrotskin
|
- POSTGRES_DB=carrotskin
|
||||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
volumes:
|
volumes:
|
||||||
@@ -108,7 +111,7 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
|
|
||||||
# ==================== RustFS 对象存储 (可选) ====================
|
# ==================== RustFS 对象存储====================
|
||||||
rustfs:
|
rustfs:
|
||||||
image: ghcr.io/rustfs/rustfs:latest
|
image: ghcr.io/rustfs/rustfs:latest
|
||||||
container_name: carrotskin-rustfs
|
container_name: carrotskin-rustfs
|
||||||
@@ -148,14 +151,19 @@ services:
|
|||||||
echo '等待 RustFS 启动...';
|
echo '等待 RustFS 启动...';
|
||||||
sleep 5;
|
sleep 5;
|
||||||
mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY};
|
mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY};
|
||||||
mc mb myrustfs/$${RUSTFS_BUCKET} --ignore-existing;
|
echo '创建材质存储桶...';
|
||||||
mc anonymous set download myrustfs/$${RUSTFS_BUCKET};
|
mc mb myrustfs/$${RUSTFS_BUCKET_TEXTURES} --ignore-existing;
|
||||||
echo '存储桶 $${RUSTFS_BUCKET} 创建完成,已设置公开读取权限';
|
mc anonymous set download myrustfs/$${RUSTFS_BUCKET_TEXTURES};
|
||||||
|
echo '创建头像存储桶...';
|
||||||
|
mc mb myrustfs/$${RUSTFS_BUCKET_AVATARS} --ignore-existing;
|
||||||
|
mc anonymous set download myrustfs/$${RUSTFS_BUCKET_AVATARS};
|
||||||
|
echo '存储桶创建完成: $${RUSTFS_BUCKET_TEXTURES}, $${RUSTFS_BUCKET_AVATARS}';
|
||||||
"
|
"
|
||||||
environment:
|
environment:
|
||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
|
||||||
- RUSTFS_BUCKET=${RUSTFS_BUCKET_TEXTURES:-carrotskin}
|
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
|
||||||
|
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
|
||||||
networks:
|
networks:
|
||||||
- carrotskin-network
|
- carrotskin-network
|
||||||
profiles:
|
profiles:
|
||||||
|
|||||||
33
go.mod
33
go.mod
@@ -5,7 +5,7 @@ go 1.24.0
|
|||||||
toolchain go1.24.2
|
toolchain go1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alicebob/miniredis/v2 v2.31.1
|
github.com/chai2010/webp v1.4.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
@@ -13,36 +13,45 @@ require (
|
|||||||
github.com/minio/minio-go/v7 v7.0.97
|
github.com/minio/minio-go/v7 v7.0.97
|
||||||
github.com/redis/go-redis/v9 v9.17.2
|
github.com/redis/go-redis/v9 v9.17.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
|
github.com/swaggo/files v1.0.1
|
||||||
|
github.com/swaggo/gin-swagger v1.6.1
|
||||||
|
github.com/swaggo/swag v1.16.6
|
||||||
github.com/wenlng/go-captcha-assets v1.0.7
|
github.com/wenlng/go-captcha-assets v1.0.7
|
||||||
github.com/wenlng/go-captcha/v2 v2.0.4
|
github.com/wenlng/go-captcha/v2 v2.0.4
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
gorm.io/datatypes v1.2.7
|
gorm.io/datatypes v1.2.7
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||||
|
github.com/go-openapi/spec v0.22.1 // indirect
|
||||||
|
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||||
|
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||||
|
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||||
|
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||||
|
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||||
|
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.19.0 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
github.com/minio/crc64nvme v1.1.0 // indirect
|
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
github.com/tinylib/msgp v1.3.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
github.com/yuin/gopher-lua v1.1.0 // indirect
|
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
|
||||||
golang.org/x/image v0.33.0 // indirect
|
golang.org/x/image v0.33.0 // indirect
|
||||||
golang.org/x/mod v0.30.0 // indirect
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
|
|||||||
78
go.sum
78
go.sum
@@ -1,10 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
|
||||||
github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y=
|
|
||||||
github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
|
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
@@ -17,9 +14,8 @@ github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2N
|
|||||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -35,12 +31,41 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
|||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||||
|
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||||
|
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||||
|
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||||
|
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||||
|
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||||
|
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||||
|
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||||
|
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||||
|
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||||
|
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||||
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||||
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||||
|
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||||
|
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||||
|
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||||
|
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||||
|
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||||
|
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||||
|
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||||
|
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||||
|
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||||
|
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||||
|
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||||
|
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -55,8 +80,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
@@ -65,7 +90,6 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei
|
|||||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
@@ -108,8 +132,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
|
|||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||||
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||||
@@ -125,10 +149,10 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
|||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
@@ -159,8 +183,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||||
|
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||||
|
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||||
|
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||||
|
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||||
|
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||||
|
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
@@ -170,12 +200,10 @@ github.com/wenlng/go-captcha-assets v1.0.7/go.mod h1:zinRACsdYcL/S6pHgI9Iv7FKTU4
|
|||||||
github.com/wenlng/go-captcha/v2 v2.0.4 h1:5cSUF36ZyA03qeDMjKmeXGpbYJMXEexZIYK3Vga3ME0=
|
github.com/wenlng/go-captcha/v2 v2.0.4 h1:5cSUF36ZyA03qeDMjKmeXGpbYJMXEexZIYK3Vga3ME0=
|
||||||
github.com/wenlng/go-captcha/v2 v2.0.4/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34=
|
github.com/wenlng/go-captcha/v2 v2.0.4/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
|
|
||||||
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
@@ -199,6 +227,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
|||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -206,7 +235,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -226,6 +254,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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
|
||||||
@@ -40,10 +41,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 创建依赖容器
|
||||||
@@ -60,14 +61,6 @@ func NewContainer(
|
|||||||
Prefix: "carrotskin:",
|
Prefix: "carrotskin:",
|
||||||
Expiration: 5 * time.Minute,
|
Expiration: 5 * time.Minute,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Policy: database.CachePolicy{
|
|
||||||
UserTTL: 5 * time.Minute,
|
|
||||||
UserEmailTTL: 5 * time.Minute,
|
|
||||||
ProfileTTL: 5 * time.Minute,
|
|
||||||
ProfileListTTL: 3 * time.Minute,
|
|
||||||
TextureTTL: 5 * time.Minute,
|
|
||||||
TextureListTTL: 2 * time.Minute,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
c := &Container{
|
c := &Container{
|
||||||
@@ -83,6 +76,7 @@ func NewContainer(
|
|||||||
c.UserRepo = repository.NewUserRepository(db)
|
c.UserRepo = repository.NewUserRepository(db)
|
||||||
c.ProfileRepo = repository.NewProfileRepository(db)
|
c.ProfileRepo = repository.NewProfileRepository(db)
|
||||||
c.TextureRepo = repository.NewTextureRepository(db)
|
c.TextureRepo = repository.NewTextureRepository(db)
|
||||||
|
c.TokenRepo = repository.NewTokenRepository(db)
|
||||||
c.ClientRepo = repository.NewClientRepository(db)
|
c.ClientRepo = repository.NewClientRepository(db)
|
||||||
c.ConfigRepo = repository.NewSystemConfigRepository(db)
|
c.ConfigRepo = repository.NewSystemConfigRepository(db)
|
||||||
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
|
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
|
||||||
@@ -92,9 +86,10 @@ func NewContainer(
|
|||||||
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
|
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
|
||||||
|
|
||||||
// 初始化Service(注入缓存管理器)
|
// 初始化Service(注入缓存管理器)
|
||||||
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, logger)
|
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, storageClient, logger)
|
||||||
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
|
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
|
||||||
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
|
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
|
||||||
|
c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger)
|
||||||
|
|
||||||
// 获取Yggdrasil私钥并创建JWT服务(TokenService需要)
|
// 获取Yggdrasil私钥并创建JWT服务(TokenService需要)
|
||||||
// 注意:这里仍然需要预先初始化,因为TokenService在创建时需要YggdrasilJWT
|
// 注意:这里仍然需要预先初始化,因为TokenService在创建时需要YggdrasilJWT
|
||||||
@@ -104,28 +99,13 @@ func NewContainer(
|
|||||||
logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err))
|
logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
yggdrasilJWT := auth.NewYggdrasilJWTService(privateKey, "carrotskin")
|
yggdrasilJWT := auth.NewYggdrasilJWTService(privateKey, "carrotskin")
|
||||||
|
c.TokenService = service.NewTokenServiceJWT(c.TokenRepo, c.ClientRepo, c.ProfileRepo, yggdrasilJWT, logger)
|
||||||
// 创建Redis Token存储(必须使用Redis,包括miniredis回退)
|
|
||||||
if redisClient == nil {
|
|
||||||
logger.Fatal("Redis客户端未初始化,无法创建Token服务")
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenStore := auth.NewTokenStoreRedis(
|
|
||||||
redisClient,
|
|
||||||
logger,
|
|
||||||
auth.WithKeyPrefix("token:"),
|
|
||||||
auth.WithDefaultTTL(24*time.Hour),
|
|
||||||
auth.WithStaleTTL(30*24*time.Hour),
|
|
||||||
auth.WithMaxTokensPerUser(10),
|
|
||||||
)
|
|
||||||
c.TokenService = service.NewTokenServiceRedis(tokenStore, c.ClientRepo, c.ProfileRepo, yggdrasilJWT, logger)
|
|
||||||
|
|
||||||
// 使用组合服务(内部包含认证、会话、序列化、证书服务)
|
// 使用组合服务(内部包含认证、会话、序列化、证书服务)
|
||||||
c.YggdrasilService = service.NewYggdrasilServiceComposite(db, c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.SignatureService, redisClient, logger, c.TokenService)
|
c.YggdrasilService = service.NewYggdrasilServiceComposite(db, c.UserRepo, c.ProfileRepo, c.TokenRepo, c.YggdrasilRepo, c.SignatureService, redisClient, logger)
|
||||||
|
|
||||||
// 初始化其他服务
|
// 初始化其他服务
|
||||||
c.SecurityService = service.NewSecurityService(redisClient)
|
c.SecurityService = service.NewSecurityService(redisClient)
|
||||||
c.UploadService = service.NewUploadService(storageClient)
|
|
||||||
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
|
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
|
||||||
|
|
||||||
// 初始化VerificationService(需要email.Service)
|
// 初始化VerificationService(需要email.Service)
|
||||||
@@ -206,6 +186,13 @@ func WithTextureRepo(repo repository.TextureRepository) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTokenRepo 设置令牌仓储
|
||||||
|
func WithTokenRepo(repo repository.TokenRepository) Option {
|
||||||
|
return func(c *Container) {
|
||||||
|
c.TokenRepo = repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithConfigRepo 设置系统配置仓储
|
// WithConfigRepo 设置系统配置仓储
|
||||||
func WithConfigRepo(repo repository.SystemConfigRepository) Option {
|
func WithConfigRepo(repo repository.SystemConfigRepository) Option {
|
||||||
return func(c *Container) {
|
return func(c *Container) {
|
||||||
@@ -262,13 +249,6 @@ func WithVerificationService(svc service.VerificationService) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithUploadService 设置上传服务
|
|
||||||
func WithUploadService(svc service.UploadService) Option {
|
|
||||||
return func(c *Container) {
|
|
||||||
c.UploadService = svc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSecurityService 设置安全服务
|
// WithSecurityService 设置安全服务
|
||||||
func WithSecurityService(svc service.SecurityService) Option {
|
func WithSecurityService(svc service.SecurityService) Option {
|
||||||
return func(c *Container) {
|
return func(c *Container) {
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package errors
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAppErrorBasics(t *testing.T) {
|
|
||||||
root := errors.New("root")
|
|
||||||
appErr := NewBadRequest("bad", root)
|
|
||||||
|
|
||||||
if appErr.Code != 400 || appErr.Message != "bad" {
|
|
||||||
t.Fatalf("unexpected appErr fields: %+v", appErr)
|
|
||||||
}
|
|
||||||
if got := appErr.Error(); got != "bad: root" {
|
|
||||||
t.Fatalf("unexpected Error(): %s", got)
|
|
||||||
}
|
|
||||||
if !Is(appErr, root) {
|
|
||||||
t.Fatalf("Is should match wrapped error")
|
|
||||||
}
|
|
||||||
var target *AppError
|
|
||||||
if !As(appErr, &target) {
|
|
||||||
t.Fatalf("As should succeed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrap(t *testing.T) {
|
|
||||||
if Wrap(nil, "msg") != nil {
|
|
||||||
t.Fatalf("Wrap nil should return nil")
|
|
||||||
}
|
|
||||||
err := errors.New("base")
|
|
||||||
wrapped := Wrap(err, "ctx")
|
|
||||||
if wrapped.Error() != "ctx: base" {
|
|
||||||
t.Fatalf("wrap message mismatch: %v", wrapped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +70,6 @@ func ProfileToProfileInfo(profile *model.Profile) *types.ProfileInfo {
|
|||||||
Name: profile.Name,
|
Name: profile.Name,
|
||||||
SkinID: profile.SkinID,
|
SkinID: profile.SkinID,
|
||||||
CapeID: profile.CapeID,
|
CapeID: profile.CapeID,
|
||||||
IsActive: profile.IsActive,
|
|
||||||
LastUsedAt: profile.LastUsedAt,
|
LastUsedAt: profile.LastUsedAt,
|
||||||
CreatedAt: profile.CreatedAt,
|
CreatedAt: profile.CreatedAt,
|
||||||
UpdatedAt: profile.UpdatedAt,
|
UpdatedAt: profile.UpdatedAt,
|
||||||
|
|||||||
@@ -207,39 +207,3 @@ func (h *ProfileHandler) Delete(c *gin.Context) {
|
|||||||
|
|
||||||
RespondSuccess(c, gin.H{"message": "删除成功"})
|
RespondSuccess(c, gin.H{"message": "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActive 设置活跃档案
|
|
||||||
// @Summary 设置活跃档案
|
|
||||||
// @Description 将指定档案设置为活跃状态
|
|
||||||
// @Tags profile
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Param uuid path string true "档案UUID"
|
|
||||||
// @Success 200 {object} model.Response "设置成功"
|
|
||||||
// @Failure 403 {object} model.ErrorResponse "无权操作"
|
|
||||||
// @Router /api/v1/profile/{uuid}/activate [post]
|
|
||||||
func (h *ProfileHandler) SetActive(c *gin.Context) {
|
|
||||||
userID, ok := GetUserIDFromContext(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uuid := c.Param("uuid")
|
|
||||||
if uuid == "" {
|
|
||||||
RespondBadRequest(c, "UUID不能为空", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.container.ProfileService.SetActive(c.Request.Context(), uuid, userID); err != nil {
|
|
||||||
h.logger.Error("设置活跃档案失败",
|
|
||||||
zap.String("uuid", uuid),
|
|
||||||
zap.Int64("user_id", userID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
RespondWithError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
RespondSuccess(c, gin.H{"message": "设置成功"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"carrotskin/pkg/auth"
|
"carrotskin/pkg/auth"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
swaggerFiles "github.com/swaggo/files"
|
||||||
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handlers 集中管理所有Handler
|
// Handlers 集中管理所有Handler
|
||||||
@@ -38,6 +40,9 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
|
|||||||
// 健康检查路由
|
// 健康检查路由
|
||||||
router.GET("/health", HealthCheck)
|
router.GET("/health", HealthCheck)
|
||||||
|
|
||||||
|
// Swagger文档路由
|
||||||
|
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
|
|
||||||
// 创建Handler实例
|
// 创建Handler实例
|
||||||
h := NewHandlers(c)
|
h := NewHandlers(c)
|
||||||
|
|
||||||
@@ -90,8 +95,8 @@ func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JW
|
|||||||
userGroup.PUT("/profile", h.UpdateProfile)
|
userGroup.PUT("/profile", h.UpdateProfile)
|
||||||
|
|
||||||
// 头像相关
|
// 头像相关
|
||||||
userGroup.POST("/avatar/upload-url", h.GenerateAvatarUploadURL)
|
userGroup.POST("/avatar/upload", h.UploadAvatar) // 直接上传头像文件
|
||||||
userGroup.PUT("/avatar", h.UpdateAvatar)
|
userGroup.PUT("/avatar", h.UpdateAvatar) // 更新头像URL(外部URL)
|
||||||
|
|
||||||
// 更换邮箱
|
// 更换邮箱
|
||||||
userGroup.POST("/change-email", h.ChangeEmail)
|
userGroup.POST("/change-email", h.ChangeEmail)
|
||||||
@@ -108,14 +113,16 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a
|
|||||||
// 公开路由(无需认证)
|
// 公开路由(无需认证)
|
||||||
textureGroup.GET("", h.Search)
|
textureGroup.GET("", h.Search)
|
||||||
textureGroup.GET("/:id", h.Get)
|
textureGroup.GET("/:id", h.Get)
|
||||||
|
textureGroup.GET("/:id/render", h.RenderTexture) // type/front/back/full/head/isometric
|
||||||
|
textureGroup.GET("/:id/avatar", h.RenderAvatar) // mode=2d/3d
|
||||||
|
textureGroup.GET("/:id/cape", h.RenderCape)
|
||||||
|
textureGroup.GET("/:id/preview", h.RenderPreview) // 自动根据类型预览
|
||||||
|
|
||||||
// 需要认证的路由
|
// 需要认证的路由
|
||||||
textureAuth := textureGroup.Group("")
|
textureAuth := textureGroup.Group("")
|
||||||
textureAuth.Use(middleware.AuthMiddleware(jwtService))
|
textureAuth.Use(middleware.AuthMiddleware(jwtService))
|
||||||
{
|
{
|
||||||
textureAuth.POST("/upload", h.Upload) // 直接上传文件
|
textureAuth.POST("/upload", h.Upload) // 直接上传文件
|
||||||
textureAuth.POST("/upload-url", h.GenerateUploadURL) // 生成预签名URL(保留兼容性)
|
|
||||||
textureAuth.POST("", h.Create) // 创建材质记录(配合预签名URL使用)
|
|
||||||
textureAuth.PUT("/:id", h.Update)
|
textureAuth.PUT("/:id", h.Update)
|
||||||
textureAuth.DELETE("/:id", h.Delete)
|
textureAuth.DELETE("/:id", h.Delete)
|
||||||
textureAuth.POST("/:id/favorite", h.ToggleFavorite)
|
textureAuth.POST("/:id/favorite", h.ToggleFavorite)
|
||||||
@@ -143,7 +150,6 @@ func registerProfileRoutesWithDI(v1 *gin.RouterGroup, h *ProfileHandler, jwtServ
|
|||||||
profileAuth.GET("/", h.List)
|
profileAuth.GET("/", h.List)
|
||||||
profileAuth.PUT("/:uuid", h.Update)
|
profileAuth.PUT("/:uuid", h.Update)
|
||||||
profileAuth.DELETE("/:uuid", h.Delete)
|
profileAuth.DELETE("/:uuid", h.Delete)
|
||||||
profileAuth.POST("/:uuid/activate", h.SetActive)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 仅验证降级路径(未初始化依赖时的响应)
|
|
||||||
func TestHealthCheck_Degraded(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
router := gin.New()
|
|
||||||
router.GET("/health", HealthCheck)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("expected 503 when dependencies missing, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"carrotskin/internal/container"
|
"carrotskin/internal/container"
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/service"
|
||||||
"carrotskin/internal/types"
|
"carrotskin/internal/types"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -24,93 +25,6 @@ func NewTextureHandler(c *container.Container) *TextureHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateUploadURL 生成材质上传URL
|
|
||||||
func (h *TextureHandler) GenerateUploadURL(c *gin.Context) {
|
|
||||||
userID, ok := GetUserIDFromContext(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req types.GenerateTextureUploadURLRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
RespondBadRequest(c, "请求参数错误", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.container.Storage == nil {
|
|
||||||
RespondServerError(c, "存储服务不可用", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.container.UploadService.GenerateTextureUploadURL(
|
|
||||||
c.Request.Context(),
|
|
||||||
userID,
|
|
||||||
req.FileName,
|
|
||||||
string(req.TextureType),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("生成材质上传URL失败",
|
|
||||||
zap.Int64("user_id", userID),
|
|
||||||
zap.String("file_name", req.FileName),
|
|
||||||
zap.String("texture_type", string(req.TextureType)),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
RespondBadRequest(c, err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
RespondSuccess(c, &types.GenerateTextureUploadURLResponse{
|
|
||||||
PostURL: result.PostURL,
|
|
||||||
FormData: result.FormData,
|
|
||||||
TextureURL: result.FileURL,
|
|
||||||
ExpiresIn: 900,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create 创建材质记录
|
|
||||||
func (h *TextureHandler) Create(c *gin.Context) {
|
|
||||||
userID, ok := GetUserIDFromContext(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req types.CreateTextureRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
RespondBadRequest(c, "请求参数错误", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
maxTextures := h.container.UserService.GetMaxTexturesPerUser()
|
|
||||||
if err := h.container.TextureService.CheckUploadLimit(c.Request.Context(), userID, maxTextures); err != nil {
|
|
||||||
RespondBadRequest(c, err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
texture, err := h.container.TextureService.Create(
|
|
||||||
c.Request.Context(),
|
|
||||||
userID,
|
|
||||||
req.Name,
|
|
||||||
req.Description,
|
|
||||||
string(req.Type),
|
|
||||||
req.URL,
|
|
||||||
req.Hash,
|
|
||||||
req.Size,
|
|
||||||
req.IsPublic,
|
|
||||||
req.IsSlim,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("创建材质失败",
|
|
||||||
zap.Int64("user_id", userID),
|
|
||||||
zap.String("name", req.Name),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
RespondBadRequest(c, err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
RespondSuccess(c, TextureToTextureInfo(texture))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 获取材质详情
|
// Get 获取材质详情
|
||||||
func (h *TextureHandler) Get(c *gin.Context) {
|
func (h *TextureHandler) Get(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
@@ -171,6 +85,98 @@ func (h *TextureHandler) Search(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderTexture 渲染皮肤/披风预览
|
||||||
|
func (h *TextureHandler) RenderTexture(c *gin.Context) {
|
||||||
|
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
RespondBadRequest(c, "无效的材质ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderType := service.RenderType(c.DefaultQuery("type", string(service.RenderTypeIsometric)))
|
||||||
|
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
|
||||||
|
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
|
||||||
|
|
||||||
|
result, err := h.container.TextureRenderService.RenderTexture(c.Request.Context(), textureID, renderType, size, format)
|
||||||
|
if err != nil {
|
||||||
|
RespondBadRequest(c, err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RespondSuccess(c, toRenderResponse(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderAvatar 渲染头像(2D/3D)
|
||||||
|
func (h *TextureHandler) RenderAvatar(c *gin.Context) {
|
||||||
|
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
RespondBadRequest(c, "无效的材质ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := service.AvatarMode(c.DefaultQuery("mode", string(service.AvatarMode2D)))
|
||||||
|
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
|
||||||
|
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
|
||||||
|
|
||||||
|
result, err := h.container.TextureRenderService.RenderAvatar(c.Request.Context(), textureID, size, mode, format)
|
||||||
|
if err != nil {
|
||||||
|
RespondBadRequest(c, err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RespondSuccess(c, toRenderResponse(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderCape 渲染披风
|
||||||
|
func (h *TextureHandler) RenderCape(c *gin.Context) {
|
||||||
|
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
RespondBadRequest(c, "无效的材质ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
|
||||||
|
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
|
||||||
|
|
||||||
|
result, err := h.container.TextureRenderService.RenderCape(c.Request.Context(), textureID, size, format)
|
||||||
|
if err != nil {
|
||||||
|
RespondBadRequest(c, err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RespondSuccess(c, toRenderResponse(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPreview 自动选择预览(皮肤走等距,披风走披风渲染)
|
||||||
|
func (h *TextureHandler) RenderPreview(c *gin.Context) {
|
||||||
|
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
RespondBadRequest(c, "无效的材质ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
|
||||||
|
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
|
||||||
|
|
||||||
|
result, err := h.container.TextureRenderService.RenderPreview(c.Request.Context(), textureID, size, format)
|
||||||
|
if err != nil {
|
||||||
|
RespondBadRequest(c, err.Error(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RespondSuccess(c, toRenderResponse(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// toRenderResponse 转换为API响应
|
||||||
|
func toRenderResponse(r *service.RenderResult) *types.RenderResponse {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resp := &types.RenderResponse{
|
||||||
|
URL: r.URL,
|
||||||
|
ContentType: r.ContentType,
|
||||||
|
ETag: r.ETag,
|
||||||
|
Size: r.Size,
|
||||||
|
}
|
||||||
|
if !r.LastModified.IsZero() {
|
||||||
|
t := r.LastModified
|
||||||
|
resp.LastModified = &t
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
// Update 更新材质
|
// Update 更新材质
|
||||||
func (h *TextureHandler) Update(c *gin.Context) {
|
func (h *TextureHandler) Update(c *gin.Context) {
|
||||||
userID, ok := GetUserIDFromContext(c)
|
userID, ok := GetUserIDFromContext(c)
|
||||||
|
|||||||
@@ -102,44 +102,66 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
|||||||
RespondSuccess(c, UserToUserInfo(updatedUser))
|
RespondSuccess(c, UserToUserInfo(updatedUser))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateAvatarUploadURL 生成头像上传URL
|
// UploadAvatar 直接上传头像文件
|
||||||
func (h *UserHandler) GenerateAvatarUploadURL(c *gin.Context) {
|
func (h *UserHandler) UploadAvatar(c *gin.Context) {
|
||||||
userID, ok := GetUserIDFromContext(c)
|
userID, ok := GetUserIDFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req types.GenerateAvatarUploadURLRequest
|
// 解析multipart表单
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.Request.ParseMultipartForm(10 << 20); err != nil { // 10MB
|
||||||
RespondBadRequest(c, "请求参数错误", err)
|
RespondBadRequest(c, "解析表单失败", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.container.Storage == nil {
|
// 获取文件
|
||||||
RespondServerError(c, "存储服务不可用", nil)
|
file, err := c.FormFile("file")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.container.UploadService.GenerateAvatarUploadURL(c.Request.Context(), userID, req.FileName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("生成头像上传URL失败",
|
RespondBadRequest(c, "获取文件失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
RespondBadRequest(c, "打开文件失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
fileData := make([]byte, file.Size)
|
||||||
|
if _, err := src.Read(fileData); err != nil {
|
||||||
|
RespondBadRequest(c, "读取文件失败", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用服务上传头像
|
||||||
|
avatarURL, err := h.container.UserService.UploadAvatar(c.Request.Context(), userID, fileData, file.Filename)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("上传头像失败",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
zap.String("file_name", req.FileName),
|
zap.String("file_name", file.Filename),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
RespondBadRequest(c, err.Error(), nil)
|
RespondBadRequest(c, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
RespondSuccess(c, &types.GenerateAvatarUploadURLResponse{
|
// 获取更新后的用户信息
|
||||||
PostURL: result.PostURL,
|
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
|
||||||
FormData: result.FormData,
|
if err != nil || user == nil {
|
||||||
AvatarURL: result.FileURL,
|
RespondNotFound(c, "用户不存在")
|
||||||
ExpiresIn: 900,
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RespondSuccess(c, gin.H{
|
||||||
|
"avatar_url": avatarURL,
|
||||||
|
"user": UserToUserInfo(user),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAvatar 更新头像URL
|
// UpdateAvatar 更新头像URL(保留用于外部URL)
|
||||||
func (h *UserHandler) UpdateAvatar(c *gin.Context) {
|
func (h *UserHandler) UpdateAvatar(c *gin.Context) {
|
||||||
userID, ok := GetUserIDFromContext(c)
|
userID, ok := GetUserIDFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -23,3 +23,9 @@ type BaseModel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,3 @@ func (Client) TableName() string {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ import (
|
|||||||
// Profile Minecraft 档案模型
|
// Profile Minecraft 档案模型
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"`
|
UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"`
|
||||||
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1;index:idx_profiles_user_active,priority:1" json:"user_id"`
|
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"`
|
||||||
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名
|
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名
|
||||||
SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"`
|
SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"`
|
||||||
CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"`
|
CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"`
|
||||||
RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` // RSA 私钥不返回给前端
|
RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` // RSA 私钥不返回给前端
|
||||||
IsActive bool `gorm:"column:is_active;not null;default:true;index:idx_profiles_user_active,priority:2" json:"is_active"`
|
|
||||||
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"`
|
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||||
@@ -33,7 +32,6 @@ type ProfileResponse struct {
|
|||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Textures ProfileTexturesData `json:"textures"`
|
Textures ProfileTexturesData `json:"textures"`
|
||||||
IsActive bool `json:"is_active"`
|
|
||||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
23
internal/model/token.go
Normal file
23
internal/model/token.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Token Yggdrasil 认证令牌模型
|
||||||
|
type Token struct {
|
||||||
|
AccessToken string `gorm:"column:access_token;type:text;primaryKey" json:"access_token"` // 改为text以支持JWT长度
|
||||||
|
UserID int64 `gorm:"column:user_id;not null;index:idx_tokens_user_id" json:"user_id"`
|
||||||
|
ClientToken string `gorm:"column:client_token;type:varchar(64);not null;index:idx_tokens_client_token" json:"client_token"`
|
||||||
|
ProfileId string `gorm:"column:profile_id;type:varchar(36);index:idx_tokens_profile_id" json:"profile_id"` // 改为可空
|
||||||
|
Version int `gorm:"column:version;not null;default:0;index:idx_tokens_version" json:"version"` // 新增:版本号
|
||||||
|
Usable bool `gorm:"column:usable;not null;default:true;index:idx_tokens_usable" json:"usable"`
|
||||||
|
IssueDate time.Time `gorm:"column:issue_date;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_tokens_issue_date,sort:desc" json:"issue_date"`
|
||||||
|
ExpiresAt *time.Time `gorm:"column:expires_at;type:timestamp" json:"expires_at,omitempty"` // 新增:过期时间
|
||||||
|
StaleAt *time.Time `gorm:"column:stale_at;type:timestamp" json:"stale_at,omitempty"` // 新增:过期但可用时间
|
||||||
|
|
||||||
|
// 关联
|
||||||
|
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
|
||||||
|
Profile *Profile `gorm:"foreignKey:ProfileId;references:UUID;constraint:OnDelete:CASCADE" json:"profile,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (Token) TableName() string { return "tokens" }
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateRandomPassword(t *testing.T) {
|
|
||||||
pwd := GenerateRandomPassword(16)
|
|
||||||
if len(pwd) != 16 {
|
|
||||||
t.Fatalf("length mismatch: %d", len(pwd))
|
|
||||||
}
|
|
||||||
for _, ch := range pwd {
|
|
||||||
if !strings.ContainsRune(passwordChars, ch) {
|
|
||||||
t.Fatalf("unexpected char: %c", ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,6 @@ type ProfileRepository interface {
|
|||||||
Delete(ctx context.Context, uuid string) error
|
Delete(ctx context.Context, uuid string) error
|
||||||
BatchDelete(ctx context.Context, uuids []string) (int64, error) // 批量删除
|
BatchDelete(ctx context.Context, uuids []string) (int64, error) // 批量删除
|
||||||
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
||||||
SetActive(ctx context.Context, uuid string, userID int64) error
|
|
||||||
UpdateLastUsedAt(ctx context.Context, uuid string) error
|
UpdateLastUsedAt(ctx context.Context, uuid string) error
|
||||||
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
|
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
|
||||||
GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error)
|
GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error)
|
||||||
@@ -67,6 +66,18 @@ type TextureRepository interface {
|
|||||||
CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error)
|
CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TokenRepository 令牌仓储接口
|
||||||
|
type TokenRepository interface {
|
||||||
|
Create(ctx context.Context, token *model.Token) error
|
||||||
|
FindByAccessToken(ctx context.Context, accessToken string) (*model.Token, error)
|
||||||
|
GetByUserID(ctx context.Context, userId int64) ([]*model.Token, error)
|
||||||
|
GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error)
|
||||||
|
GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error)
|
||||||
|
DeleteByAccessToken(ctx context.Context, accessToken string) error
|
||||||
|
DeleteByUserID(ctx context.Context, userId int64) error
|
||||||
|
BatchDelete(ctx context.Context, accessTokens []string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
// SystemConfigRepository 系统配置仓储接口
|
// SystemConfigRepository 系统配置仓储接口
|
||||||
type SystemConfigRepository interface {
|
type SystemConfigRepository interface {
|
||||||
GetByKey(ctx context.Context, key string) (*model.SystemConfig, error)
|
GetByKey(ctx context.Context, key string) (*model.SystemConfig, error)
|
||||||
|
|||||||
@@ -109,20 +109,6 @@ func (r *profileRepository) CountByUserID(ctx context.Context, userID int64) (in
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *profileRepository) SetActive(ctx context.Context, uuid string, userID int64) error {
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&model.Profile{}).
|
|
||||||
Where("user_id = ?", userID).
|
|
||||||
Update("is_active", false).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Model(&model.Profile{}).
|
|
||||||
Where("uuid = ? AND user_id = ?", uuid, userID).
|
|
||||||
Update("is_active", true).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *profileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
|
func (r *profileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
|
||||||
return r.db.WithContext(ctx).Model(&model.Profile{}).
|
return r.db.WithContext(ctx).Model(&model.Profile{}).
|
||||||
Where("uuid = ?", uuid).
|
Where("uuid = ?", uuid).
|
||||||
|
|||||||
@@ -42,41 +42,6 @@ func TestProfileRepository_QueryConditions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProfileRepository_SetActiveLogic 测试设置活跃档案的逻辑
|
|
||||||
func TestProfileRepository_SetActiveLogic(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
uuid string
|
|
||||||
userID int64
|
|
||||||
otherProfiles int
|
|
||||||
wantAllInactive bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "设置一个档案为活跃,其他应该变为非活跃",
|
|
||||||
uuid: "profile-1",
|
|
||||||
userID: 1,
|
|
||||||
otherProfiles: 2,
|
|
||||||
wantAllInactive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "只有一个档案时",
|
|
||||||
uuid: "profile-1",
|
|
||||||
userID: 1,
|
|
||||||
otherProfiles: 0,
|
|
||||||
wantAllInactive: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// 验证逻辑:设置一个档案为活跃时,应该先将所有档案设为非活跃
|
|
||||||
if !tt.wantAllInactive {
|
|
||||||
t.Error("Setting active profile should first set all profiles to inactive")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProfileRepository_CountLogic 测试统计逻辑
|
// TestProfileRepository_CountLogic 测试统计逻辑
|
||||||
func TestProfileRepository_CountLogic(t *testing.T) {
|
func TestProfileRepository_CountLogic(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -181,4 +146,3 @@ func TestProfileRepository_FindOneProfileLogic(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"carrotskin/internal/model"
|
|
||||||
"carrotskin/internal/testutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUserRepository_BasicAndPoints(t *testing.T) {
|
|
||||||
db := testutil.NewTestDB(t)
|
|
||||||
repo := NewUserRepository(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
user := &model.User{Username: "u1", Email: "e1@test.com", Password: "pwd", Status: 1}
|
|
||||||
if err := repo.Create(ctx, user); err != nil {
|
|
||||||
t.Fatalf("create user err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if u, err := repo.FindByID(ctx, user.ID); err != nil || u.Username != "u1" {
|
|
||||||
t.Fatalf("FindByID mismatch: %v %+v", err, u)
|
|
||||||
}
|
|
||||||
if u, err := repo.FindByUsername(ctx, "u1"); err != nil || u.Email != "e1@test.com" {
|
|
||||||
t.Fatalf("FindByUsername mismatch")
|
|
||||||
}
|
|
||||||
if u, err := repo.FindByEmail(ctx, "e1@test.com"); err != nil || u.ID != user.ID {
|
|
||||||
t.Fatalf("FindByEmail mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := repo.UpdateFields(ctx, user.ID, map[string]interface{}{"avatar": "a.png"}); err != nil {
|
|
||||||
t.Fatalf("UpdateFields err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := repo.BatchUpdate(ctx, []int64{user.ID}, map[string]interface{}{"status": 2}); err != nil {
|
|
||||||
t.Fatalf("BatchUpdate err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 积分增加
|
|
||||||
if err := repo.UpdatePoints(ctx, user.ID, 10, "add", "bonus"); err != nil {
|
|
||||||
t.Fatalf("UpdatePoints add err: %v", err)
|
|
||||||
}
|
|
||||||
// 积分不足场景
|
|
||||||
if err := repo.UpdatePoints(ctx, user.ID, -100, "sub", "penalty"); err == nil {
|
|
||||||
t.Fatalf("expected insufficient points error")
|
|
||||||
}
|
|
||||||
|
|
||||||
if list, err := repo.FindByIDs(ctx, []int64{user.ID}); err != nil || len(list) != 1 {
|
|
||||||
t.Fatalf("FindByIDs mismatch: %v %d", err, len(list))
|
|
||||||
}
|
|
||||||
if list, err := repo.FindByIDs(ctx, []int64{}); err != nil || len(list) != 0 {
|
|
||||||
t.Fatalf("FindByIDs empty mismatch: %v %d", err, len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 软删除
|
|
||||||
if err := repo.Delete(ctx, user.ID); err != nil {
|
|
||||||
t.Fatalf("Delete err: %v", err)
|
|
||||||
}
|
|
||||||
deleted, _ := repo.FindByID(ctx, user.ID)
|
|
||||||
if deleted != nil {
|
|
||||||
t.Fatalf("expected deleted user filtered out")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量操作边界
|
|
||||||
if _, err := repo.BatchUpdate(ctx, []int64{}, map[string]interface{}{"status": 1}); err != nil {
|
|
||||||
t.Fatalf("BatchUpdate empty should not error: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := repo.BatchDelete(ctx, []int64{}); err != nil {
|
|
||||||
t.Fatalf("BatchDelete empty should not error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日志写入
|
|
||||||
_ = repo.CreateLoginLog(ctx, &model.UserLoginLog{UserID: user.ID, IPAddress: "127.0.0.1"})
|
|
||||||
_ = repo.CreatePointLog(ctx, &model.UserPointLog{UserID: user.ID, Amount: 1, ChangeType: "add"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProfileRepository_Basic(t *testing.T) {
|
|
||||||
db := testutil.NewTestDB(t)
|
|
||||||
userRepo := NewUserRepository(db)
|
|
||||||
profileRepo := NewProfileRepository(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
u := &model.User{Username: "u2", Email: "u2@test.com", Password: "pwd", Status: 1}
|
|
||||||
_ = userRepo.Create(ctx, u)
|
|
||||||
|
|
||||||
p := &model.Profile{UUID: "p-uuid", UserID: u.ID, Name: "hero", IsActive: false}
|
|
||||||
if err := profileRepo.Create(ctx, p); err != nil {
|
|
||||||
t.Fatalf("create profile err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, err := profileRepo.FindByUUID(ctx, "p-uuid"); err != nil || got.Name != "hero" {
|
|
||||||
t.Fatalf("FindByUUID mismatch: %v %+v", err, got)
|
|
||||||
}
|
|
||||||
if list, err := profileRepo.FindByUserID(ctx, u.ID); err != nil || len(list) != 1 {
|
|
||||||
t.Fatalf("FindByUserID mismatch")
|
|
||||||
}
|
|
||||||
if count, err := profileRepo.CountByUserID(ctx, u.ID); err != nil || count != 1 {
|
|
||||||
t.Fatalf("CountByUserID mismatch: %d err=%v", count, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := profileRepo.SetActive(ctx, "p-uuid", u.ID); err != nil {
|
|
||||||
t.Fatalf("SetActive err: %v", err)
|
|
||||||
}
|
|
||||||
if err := profileRepo.UpdateLastUsedAt(ctx, "p-uuid"); err != nil {
|
|
||||||
t.Fatalf("UpdateLastUsedAt err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, err := profileRepo.FindByName(ctx, "hero"); err != nil || got == nil {
|
|
||||||
t.Fatalf("FindByName mismatch")
|
|
||||||
}
|
|
||||||
if list, err := profileRepo.FindByUUIDs(ctx, []string{"p-uuid"}); err != nil || len(list) != 1 {
|
|
||||||
t.Fatalf("FindByUUIDs mismatch")
|
|
||||||
}
|
|
||||||
if _, err := profileRepo.BatchUpdate(ctx, []string{"p-uuid"}, map[string]interface{}{"name": "hero2"}); err != nil {
|
|
||||||
t.Fatalf("BatchUpdate profile err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := profileRepo.Delete(ctx, "p-uuid"); err != nil {
|
|
||||||
t.Fatalf("Delete err: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := profileRepo.BatchDelete(ctx, []string{}); err != nil {
|
|
||||||
t.Fatalf("BatchDelete empty err: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTextureRepository_Basic(t *testing.T) {
|
|
||||||
db := testutil.NewTestDB(t)
|
|
||||||
userRepo := NewUserRepository(db)
|
|
||||||
textureRepo := NewTextureRepository(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
u := &model.User{Username: "u3", Email: "u3@test.com", Password: "pwd", Status: 1}
|
|
||||||
_ = userRepo.Create(ctx, u)
|
|
||||||
|
|
||||||
tex := &model.Texture{
|
|
||||||
UploaderID: u.ID,
|
|
||||||
Name: "tex",
|
|
||||||
Hash: "hash1",
|
|
||||||
URL: "url1",
|
|
||||||
Type: model.TextureTypeSkin,
|
|
||||||
IsPublic: true,
|
|
||||||
Status: 1,
|
|
||||||
}
|
|
||||||
if err := textureRepo.Create(ctx, tex); err != nil {
|
|
||||||
t.Fatalf("create texture err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, _ := textureRepo.FindByHash(ctx, "hash1"); got == nil || got.ID != tex.ID {
|
|
||||||
t.Fatalf("FindByHash mismatch")
|
|
||||||
}
|
|
||||||
if got, _ := textureRepo.FindByHashAndUploaderID(ctx, "hash1", u.ID); got == nil {
|
|
||||||
t.Fatalf("FindByHashAndUploaderID mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = textureRepo.IncrementFavoriteCount(ctx, tex.ID)
|
|
||||||
_ = textureRepo.DecrementFavoriteCount(ctx, tex.ID)
|
|
||||||
_ = textureRepo.IncrementDownloadCount(ctx, tex.ID)
|
|
||||||
_ = textureRepo.CreateDownloadLog(ctx, &model.TextureDownloadLog{TextureID: tex.ID, UserID: &u.ID, IPAddress: "127.0.0.1"})
|
|
||||||
|
|
||||||
// 收藏
|
|
||||||
_ = textureRepo.AddFavorite(ctx, u.ID, tex.ID)
|
|
||||||
if fav, err := textureRepo.IsFavorited(ctx, u.ID, tex.ID); err == nil {
|
|
||||||
if !fav {
|
|
||||||
t.Fatalf("IsFavorited expected true")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Skipf("IsFavorited not supported by sqlite: %v", err)
|
|
||||||
}
|
|
||||||
_ = textureRepo.RemoveFavorite(ctx, u.ID, tex.ID)
|
|
||||||
|
|
||||||
// 批量更新与删除
|
|
||||||
if affected, err := textureRepo.BatchUpdate(ctx, []int64{tex.ID}, map[string]interface{}{"name": "tex-new"}); err != nil || affected != 1 {
|
|
||||||
t.Fatalf("BatchUpdate mismatch, affected=%d err=%v", affected, err)
|
|
||||||
}
|
|
||||||
if affected, err := textureRepo.BatchDelete(ctx, []int64{tex.ID}); err != nil || affected != 1 {
|
|
||||||
t.Fatalf("BatchDelete mismatch, affected=%d err=%v", affected, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索与收藏列表
|
|
||||||
_ = textureRepo.Create(ctx, &model.Texture{
|
|
||||||
UploaderID: u.ID,
|
|
||||||
Name: "search-me",
|
|
||||||
Hash: "hash2",
|
|
||||||
URL: "url2",
|
|
||||||
Type: model.TextureTypeCape,
|
|
||||||
IsPublic: true,
|
|
||||||
Status: 1,
|
|
||||||
})
|
|
||||||
if list, total, err := textureRepo.Search(ctx, "search", model.TextureTypeCape, true, 1, 10); err != nil || total == 0 || len(list) == 0 {
|
|
||||||
t.Fatalf("Search mismatch, total=%d len=%d err=%v", total, len(list), err)
|
|
||||||
}
|
|
||||||
_ = textureRepo.AddFavorite(ctx, u.ID, tex.ID+1)
|
|
||||||
if favList, total, err := textureRepo.GetUserFavorites(ctx, u.ID, 1, 10); err != nil || total == 0 || len(favList) == 0 {
|
|
||||||
t.Fatalf("GetUserFavorites mismatch, total=%d len=%d err=%v", total, len(favList), err)
|
|
||||||
}
|
|
||||||
if _, total, err := textureRepo.Search(ctx, "", model.TextureTypeSkin, true, 1, 10); err != nil || total < 2 {
|
|
||||||
t.Fatalf("Search fallback mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 列表与计数
|
|
||||||
if _, total, err := textureRepo.FindByUploaderID(ctx, u.ID, 1, 10); err != nil || total != 1 {
|
|
||||||
t.Fatalf("FindByUploaderID mismatch")
|
|
||||||
}
|
|
||||||
if cnt, err := textureRepo.CountByUploaderID(ctx, u.ID); err != nil || cnt != 1 {
|
|
||||||
t.Fatalf("CountByUploaderID mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = textureRepo.Delete(ctx, tex.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemConfigRepository_Basic(t *testing.T) {
|
|
||||||
db := testutil.NewTestDB(t)
|
|
||||||
repo := NewSystemConfigRepository(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
cfg := &model.SystemConfig{Key: "site_name", Value: "Carrot", IsPublic: true}
|
|
||||||
if err := repo.Update(ctx, cfg); err != nil {
|
|
||||||
t.Fatalf("Update err: %v", err)
|
|
||||||
}
|
|
||||||
if v, err := repo.GetByKey(ctx, "site_name"); err != nil || v.Value != "Carrot" {
|
|
||||||
t.Fatalf("GetByKey mismatch")
|
|
||||||
}
|
|
||||||
_ = repo.UpdateValue(ctx, "site_name", "Carrot2")
|
|
||||||
if list, _ := repo.GetPublic(ctx); len(list) == 0 {
|
|
||||||
t.Fatalf("GetPublic expected entries")
|
|
||||||
}
|
|
||||||
if all, _ := repo.GetAll(ctx); len(all) == 0 {
|
|
||||||
t.Fatalf("GetAll expected entries")
|
|
||||||
}
|
|
||||||
if v, _ := repo.GetByKey(ctx, "site_name"); v.Value != "Carrot2" {
|
|
||||||
t.Fatalf("UpdateValue not applied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientRepository_Basic(t *testing.T) {
|
|
||||||
db := testutil.NewTestDB(t)
|
|
||||||
repo := NewClientRepository(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
client := &model.Client{UUID: "c-uuid", ClientToken: "ct-1", UserID: 9, Version: 1}
|
|
||||||
if err := repo.Create(ctx, client); err != nil {
|
|
||||||
t.Fatalf("Create client err: %v", err)
|
|
||||||
}
|
|
||||||
if got, _ := repo.FindByClientToken(ctx, "ct-1"); got == nil || got.UUID != "c-uuid" {
|
|
||||||
t.Fatalf("FindByClientToken mismatch")
|
|
||||||
}
|
|
||||||
if got, _ := repo.FindByUUID(ctx, "c-uuid"); got == nil || got.ClientToken != "ct-1" {
|
|
||||||
t.Fatalf("FindByUUID mismatch")
|
|
||||||
}
|
|
||||||
if list, _ := repo.FindByUserID(ctx, 9); len(list) != 1 {
|
|
||||||
t.Fatalf("FindByUserID mismatch")
|
|
||||||
}
|
|
||||||
_ = repo.IncrementVersion(ctx, "c-uuid")
|
|
||||||
updated, _ := repo.FindByUUID(ctx, "c-uuid")
|
|
||||||
if updated.Version != 2 {
|
|
||||||
t.Fatalf("IncrementVersion not applied, got %d", updated.Version)
|
|
||||||
}
|
|
||||||
_ = repo.DeleteByClientToken(ctx, "ct-1")
|
|
||||||
_ = repo.DeleteByUserID(ctx, 9)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestYggdrasilRepository_Basic(t *testing.T) {
|
|
||||||
db := testutil.NewTestDB(t)
|
|
||||||
userRepo := NewUserRepository(db)
|
|
||||||
yggRepo := NewYggdrasilRepository(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
user := &model.User{Username: "u-ygg", Email: "ygg@test.com", Password: "pwd", Status: 1}
|
|
||||||
_ = userRepo.Create(ctx, user) // AfterCreate 会生成 yggdrasil 记录
|
|
||||||
|
|
||||||
pwd, err := yggRepo.GetPasswordByID(ctx, user.ID)
|
|
||||||
if err != nil || pwd == "" {
|
|
||||||
t.Fatalf("GetPasswordByID err=%v pwd=%s", err, pwd)
|
|
||||||
}
|
|
||||||
if err := yggRepo.ResetPassword(ctx, user.ID, "newpwd"); err != nil {
|
|
||||||
t.Fatalf("ResetPassword err: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
71
internal/repository/token_repository.go
Normal file
71
internal/repository/token_repository.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenRepository TokenRepository的实现
|
||||||
|
type tokenRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenRepository 创建TokenRepository实例
|
||||||
|
func NewTokenRepository(db *gorm.DB) TokenRepository {
|
||||||
|
return &tokenRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tokenRepository) Create(ctx context.Context, token *model.Token) error {
|
||||||
|
return r.db.WithContext(ctx).Create(token).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tokenRepository) FindByAccessToken(ctx context.Context, accessToken string) (*model.Token, error) {
|
||||||
|
var token model.Token
|
||||||
|
err := r.db.WithContext(ctx).Where("access_token = ?", accessToken).First(&token).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tokenRepository) GetByUserID(ctx context.Context, userId int64) ([]*model.Token, error) {
|
||||||
|
var tokens []*model.Token
|
||||||
|
err := r.db.WithContext(ctx).Where("user_id = ?", userId).Find(&tokens).Error
|
||||||
|
return tokens, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tokenRepository) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
|
||||||
|
var token model.Token
|
||||||
|
err := r.db.WithContext(ctx).Select("profile_id").Where("access_token = ?", accessToken).First(&token).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token.ProfileId, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tokenRepository) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
|
||||||
|
var token model.Token
|
||||||
|
err := r.db.WithContext(ctx).Select("user_id").Where("access_token = ?", accessToken).First(&token).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return token.UserID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tokenRepository) DeleteByAccessToken(ctx context.Context, accessToken string) error {
|
||||||
|
return r.db.WithContext(ctx).Where("access_token = ?", accessToken).Delete(&model.Token{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tokenRepository) DeleteByUserID(ctx context.Context, userId int64) error {
|
||||||
|
return r.db.WithContext(ctx).Where("user_id = ?", userId).Delete(&model.Token{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tokenRepository) BatchDelete(ctx context.Context, accessTokens []string) (int64, error) {
|
||||||
|
if len(accessTokens) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
result := r.db.WithContext(ctx).Where("access_token IN ?", accessTokens).Delete(&model.Token{})
|
||||||
|
return result.RowsAffected, result.Error
|
||||||
|
}
|
||||||
123
internal/repository/token_repository_test.go
Normal file
123
internal/repository/token_repository_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTokenRepository_BatchDeleteLogic 测试批量删除逻辑
|
||||||
|
func TestTokenRepository_BatchDeleteLogic(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tokensToDelete []string
|
||||||
|
wantCount int64
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "有效的token列表",
|
||||||
|
tokensToDelete: []string{"token1", "token2", "token3"},
|
||||||
|
wantCount: 3,
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "空列表应该返回0",
|
||||||
|
tokensToDelete: []string{},
|
||||||
|
wantCount: 0,
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "单个token",
|
||||||
|
tokensToDelete: []string{"token1"},
|
||||||
|
wantCount: 1,
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 验证批量删除逻辑:空列表应该直接返回0
|
||||||
|
if len(tt.tokensToDelete) == 0 {
|
||||||
|
if tt.wantCount != 0 {
|
||||||
|
t.Errorf("Empty list should return count 0, got %d", tt.wantCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenRepository_QueryConditions 测试token查询条件逻辑
|
||||||
|
func TestTokenRepository_QueryConditions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
accessToken string
|
||||||
|
userID int64
|
||||||
|
wantValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "有效的access token",
|
||||||
|
accessToken: "valid-token-123",
|
||||||
|
userID: 1,
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "access token为空",
|
||||||
|
accessToken: "",
|
||||||
|
userID: 1,
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "用户ID为0",
|
||||||
|
accessToken: "valid-token-123",
|
||||||
|
userID: 0,
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
isValid := tt.accessToken != "" && tt.userID > 0
|
||||||
|
if isValid != tt.wantValid {
|
||||||
|
t.Errorf("Query condition validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenRepository_FindTokenByIDLogic 测试根据ID查找token的逻辑
|
||||||
|
func TestTokenRepository_FindTokenByIDLogic(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
accessToken string
|
||||||
|
resultCount int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "找到token",
|
||||||
|
accessToken: "token-123",
|
||||||
|
resultCount: 1,
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "未找到token",
|
||||||
|
accessToken: "token-123",
|
||||||
|
resultCount: 0,
|
||||||
|
wantError: true, // 访问索引0会panic
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "找到多个token(异常情况)",
|
||||||
|
accessToken: "token-123",
|
||||||
|
resultCount: 2,
|
||||||
|
wantError: false, // 返回第一个
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 验证逻辑:如果结果为空,访问索引0会出错
|
||||||
|
hasError := tt.resultCount == 0
|
||||||
|
if hasError != tt.wantError {
|
||||||
|
t.Errorf("FindTokenByID logic failed: got error=%v, want error=%v", hasError, tt.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -29,3 +29,9 @@ func (r *yggdrasilRepository) GetPasswordByID(ctx context.Context, id int64) (st
|
|||||||
func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, password string) error {
|
func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, password string) error {
|
||||||
return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error
|
return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ type UserService interface {
|
|||||||
ResetPassword(ctx context.Context, email, newPassword string) error
|
ResetPassword(ctx context.Context, email, newPassword string) error
|
||||||
ChangeEmail(ctx context.Context, userID int64, newEmail string) error
|
ChangeEmail(ctx context.Context, userID int64, newEmail string) error
|
||||||
|
|
||||||
|
// 头像上传
|
||||||
|
UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error)
|
||||||
|
|
||||||
// URL验证
|
// URL验证
|
||||||
ValidateAvatarURL(ctx context.Context, avatarURL string) error
|
ValidateAvatarURL(ctx context.Context, avatarURL string) error
|
||||||
|
|
||||||
@@ -45,7 +48,6 @@ type ProfileService interface {
|
|||||||
Delete(ctx context.Context, uuid string, userID int64) error
|
Delete(ctx context.Context, uuid string, userID int64) error
|
||||||
|
|
||||||
// 档案状态
|
// 档案状态
|
||||||
SetActive(ctx context.Context, uuid string, userID int64) error
|
|
||||||
CheckLimit(ctx context.Context, userID int64, maxProfiles int) error
|
CheckLimit(ctx context.Context, userID int64, maxProfiles int) error
|
||||||
|
|
||||||
// 批量查询
|
// 批量查询
|
||||||
@@ -56,8 +58,7 @@ type ProfileService interface {
|
|||||||
// TextureService 材质服务接口
|
// TextureService 材质服务接口
|
||||||
type TextureService interface {
|
type TextureService interface {
|
||||||
// 材质CRUD
|
// 材质CRUD
|
||||||
Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error)
|
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error)
|
||||||
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error) // 直接上传材质文件
|
|
||||||
GetByID(ctx context.Context, id int64) (*model.Texture, error)
|
GetByID(ctx context.Context, id int64) (*model.Texture, error)
|
||||||
GetByHash(ctx context.Context, hash string) (*model.Texture, error)
|
GetByHash(ctx context.Context, hash string) (*model.Texture, error)
|
||||||
GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)
|
GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)
|
||||||
@@ -99,12 +100,6 @@ type CaptchaService interface {
|
|||||||
Verify(ctx context.Context, dx int, captchaID string) (bool, error)
|
Verify(ctx context.Context, dx int, captchaID string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadService 上传服务接口
|
|
||||||
type UploadService interface {
|
|
||||||
GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error)
|
|
||||||
GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// YggdrasilService Yggdrasil服务接口
|
// YggdrasilService Yggdrasil服务接口
|
||||||
type YggdrasilService interface {
|
type YggdrasilService interface {
|
||||||
// 用户认证
|
// 用户认证
|
||||||
@@ -141,6 +136,69 @@ type SecurityService interface {
|
|||||||
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
|
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TextureRenderService 纹理渲染服务接口
|
||||||
|
type TextureRenderService interface {
|
||||||
|
// RenderTexture 渲染纹理为预览图
|
||||||
|
RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error)
|
||||||
|
// RenderTextureFromData 从原始数据渲染纹理
|
||||||
|
RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error)
|
||||||
|
// GetRenderURL 获取渲染图的URL
|
||||||
|
GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string
|
||||||
|
// DeleteRenderCache 删除渲染缓存
|
||||||
|
DeleteRenderCache(ctx context.Context, textureID int64) error
|
||||||
|
// RenderAvatar 渲染头像(支持2D/3D模式)
|
||||||
|
RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error)
|
||||||
|
// RenderCape 渲染披风
|
||||||
|
RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
|
||||||
|
// RenderPreview 渲染预览图(类似Blessing Skin的preview功能)
|
||||||
|
RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderType 渲染类型
|
||||||
|
type RenderType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RenderTypeFront RenderType = "front" // 正面
|
||||||
|
RenderTypeBack RenderType = "back" // 背面
|
||||||
|
RenderTypeFull RenderType = "full" // 全身
|
||||||
|
RenderTypeHead RenderType = "head" // 头像
|
||||||
|
RenderTypeIsometric RenderType = "isometric" // 等距视图
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageFormat 输出格式
|
||||||
|
type ImageFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ImageFormatPNG ImageFormat = "png"
|
||||||
|
ImageFormatWEBP ImageFormat = "webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AvatarMode 头像模式
|
||||||
|
type AvatarMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AvatarMode2D AvatarMode = "2d" // 2D头像
|
||||||
|
AvatarMode3D AvatarMode = "3d" // 3D头像
|
||||||
|
)
|
||||||
|
|
||||||
|
// TextureType 纹理类型
|
||||||
|
type TextureType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TextureTypeSteve TextureType = "steve" // Steve皮肤
|
||||||
|
TextureTypeAlex TextureType = "alex" // Alex皮肤
|
||||||
|
TextureTypeCape TextureType = "cape" // 披风
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderResult 渲染结果(附带缓存/HTTP头信息)
|
||||||
|
type RenderResult struct {
|
||||||
|
URL string
|
||||||
|
ContentType string
|
||||||
|
ETag string
|
||||||
|
LastModified time.Time
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
// Services 服务集合
|
// Services 服务集合
|
||||||
type Services struct {
|
type Services struct {
|
||||||
User UserService
|
User UserService
|
||||||
@@ -149,7 +207,6 @@ type Services struct {
|
|||||||
Token TokenService
|
Token TokenService
|
||||||
Verification VerificationService
|
Verification VerificationService
|
||||||
Captcha CaptchaService
|
Captcha CaptchaService
|
||||||
Upload UploadService
|
|
||||||
Yggdrasil YggdrasilService
|
Yggdrasil YggdrasilService
|
||||||
Security SecurityService
|
Security SecurityService
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,10 +214,6 @@ func (m *MockProfileRepository) CountByUserID(ctx context.Context, userID int64)
|
|||||||
return int64(len(m.userProfiles[userID])), nil
|
return int64(len(m.userProfiles[userID])), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockProfileRepository) SetActive(ctx context.Context, uuid string, userID int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
|
func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -474,6 +470,101 @@ func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (i
|
|||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MockTokenRepository 模拟TokenRepository
|
||||||
|
type MockTokenRepository struct {
|
||||||
|
tokens map[string]*model.Token
|
||||||
|
userTokens map[int64][]*model.Token
|
||||||
|
FailCreate bool
|
||||||
|
FailFind bool
|
||||||
|
FailDelete bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockTokenRepository() *MockTokenRepository {
|
||||||
|
return &MockTokenRepository{
|
||||||
|
tokens: make(map[string]*model.Token),
|
||||||
|
userTokens: make(map[int64][]*model.Token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenRepository) Create(ctx context.Context, token *model.Token) error {
|
||||||
|
if m.FailCreate {
|
||||||
|
return errors.New("mock create error")
|
||||||
|
}
|
||||||
|
m.tokens[token.AccessToken] = token
|
||||||
|
m.userTokens[token.UserID] = append(m.userTokens[token.UserID], token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenRepository) FindByAccessToken(ctx context.Context, accessToken string) (*model.Token, error) {
|
||||||
|
if m.FailFind {
|
||||||
|
return nil, errors.New("mock find error")
|
||||||
|
}
|
||||||
|
if token, ok := m.tokens[accessToken]; ok {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenRepository) GetByUserID(ctx context.Context, userId int64) ([]*model.Token, error) {
|
||||||
|
if m.FailFind {
|
||||||
|
return nil, errors.New("mock find error")
|
||||||
|
}
|
||||||
|
return m.userTokens[userId], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenRepository) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
|
||||||
|
if m.FailFind {
|
||||||
|
return "", errors.New("mock find error")
|
||||||
|
}
|
||||||
|
if token, ok := m.tokens[accessToken]; ok {
|
||||||
|
return token.ProfileId, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenRepository) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
|
||||||
|
if m.FailFind {
|
||||||
|
return 0, errors.New("mock find error")
|
||||||
|
}
|
||||||
|
if token, ok := m.tokens[accessToken]; ok {
|
||||||
|
return token.UserID, nil
|
||||||
|
}
|
||||||
|
return 0, errors.New("token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenRepository) DeleteByAccessToken(ctx context.Context, accessToken string) error {
|
||||||
|
if m.FailDelete {
|
||||||
|
return errors.New("mock delete error")
|
||||||
|
}
|
||||||
|
delete(m.tokens, accessToken)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenRepository) DeleteByUserID(ctx context.Context, userId int64) error {
|
||||||
|
if m.FailDelete {
|
||||||
|
return errors.New("mock delete error")
|
||||||
|
}
|
||||||
|
for _, token := range m.userTokens[userId] {
|
||||||
|
delete(m.tokens, token.AccessToken)
|
||||||
|
}
|
||||||
|
m.userTokens[userId] = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenRepository) BatchDelete(ctx context.Context, accessTokens []string) (int64, error) {
|
||||||
|
if m.FailDelete {
|
||||||
|
return 0, errors.New("mock delete error")
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
for _, accessToken := range accessTokens {
|
||||||
|
if _, ok := m.tokens[accessToken]; ok {
|
||||||
|
delete(m.tokens, accessToken)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MockSystemConfigRepository 模拟SystemConfigRepository
|
// MockSystemConfigRepository 模拟SystemConfigRepository
|
||||||
type MockSystemConfigRepository struct {
|
type MockSystemConfigRepository struct {
|
||||||
configs map[string]*model.SystemConfig
|
configs map[string]*model.SystemConfig
|
||||||
@@ -713,10 +804,6 @@ func (m *MockProfileService) Delete(uuid string, userID int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockProfileService) SetActive(uuid string, userID int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockProfileService) CheckLimit(userID int64, maxProfiles int) error {
|
func (m *MockProfileService) CheckLimit(userID int64, maxProfiles int) error {
|
||||||
count := 0
|
count := 0
|
||||||
for _, profile := range m.profiles {
|
for _, profile := range m.profiles {
|
||||||
@@ -873,11 +960,90 @@ func (m *MockTextureService) CheckUploadLimit(uploaderID int64, maxTextures int)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MockTokenService 模拟TokenService
|
||||||
|
type MockTokenService struct {
|
||||||
|
tokens map[string]*model.Token
|
||||||
|
FailCreate bool
|
||||||
|
FailValidate bool
|
||||||
|
FailRefresh bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockTokenService() *MockTokenService {
|
||||||
|
return &MockTokenService{
|
||||||
|
tokens: make(map[string]*model.Token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenService) Create(userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
|
||||||
|
if m.FailCreate {
|
||||||
|
return nil, nil, "", "", errors.New("mock create error")
|
||||||
|
}
|
||||||
|
accessToken := "mock-access-token"
|
||||||
|
if clientToken == "" {
|
||||||
|
clientToken = "mock-client-token"
|
||||||
|
}
|
||||||
|
token := &model.Token{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
ClientToken: clientToken,
|
||||||
|
UserID: userID,
|
||||||
|
ProfileId: uuid,
|
||||||
|
Usable: true,
|
||||||
|
}
|
||||||
|
m.tokens[accessToken] = token
|
||||||
|
return nil, nil, accessToken, clientToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenService) Validate(accessToken, clientToken string) bool {
|
||||||
|
if m.FailValidate {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if token, ok := m.tokens[accessToken]; ok {
|
||||||
|
if clientToken == "" || token.ClientToken == clientToken {
|
||||||
|
return token.Usable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenService) Refresh(accessToken, clientToken, selectedProfileID string) (string, string, error) {
|
||||||
|
if m.FailRefresh {
|
||||||
|
return "", "", errors.New("mock refresh error")
|
||||||
|
}
|
||||||
|
return "new-access-token", clientToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenService) Invalidate(accessToken string) {
|
||||||
|
delete(m.tokens, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenService) InvalidateUserTokens(userID int64) {
|
||||||
|
for key, token := range m.tokens {
|
||||||
|
if token.UserID == userID {
|
||||||
|
delete(m.tokens, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenService) GetUUIDByAccessToken(accessToken string) (string, error) {
|
||||||
|
if token, ok := m.tokens[accessToken]; ok {
|
||||||
|
return token.ProfileId, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTokenService) GetUserIDByAccessToken(accessToken string) (int64, error) {
|
||||||
|
if token, ok := m.tokens[accessToken]; ok {
|
||||||
|
return token.UserID, nil
|
||||||
|
}
|
||||||
|
return 0, errors.New("token not found")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CacheManager Mock - 使用 database.CacheManager 的内存版本
|
// CacheManager Mock - uses database.CacheManager with nil redis
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// NewMockCacheManager 创建一个内存 CacheManager 用于测试
|
// NewMockCacheManager 创建一个禁用的 CacheManager 用于测试
|
||||||
|
// 通过设置 Enabled = false,缓存操作会被跳过,测试不依赖 Redis
|
||||||
func NewMockCacheManager() *database.CacheManager {
|
func NewMockCacheManager() *database.CacheManager {
|
||||||
return database.NewCacheManager(nil, database.CacheConfig{
|
return database.NewCacheManager(nil, database.CacheConfig{
|
||||||
Prefix: "test:",
|
Prefix: "test:",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -76,18 +77,12 @@ func (s *profileService) Create(ctx context.Context, userID int64, name string)
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: name,
|
Name: name,
|
||||||
RSAPrivateKey: privateKey,
|
RSAPrivateKey: privateKey,
|
||||||
IsActive: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.profileRepo.Create(ctx, profile); err != nil {
|
if err := s.profileRepo.Create(ctx, profile); err != nil {
|
||||||
return nil, fmt.Errorf("创建档案失败: %w", err)
|
return nil, fmt.Errorf("创建档案失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置活跃状态
|
|
||||||
if err := s.profileRepo.SetActive(ctx, profileUUID, userID); err != nil {
|
|
||||||
return nil, fmt.Errorf("设置活跃状态失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除用户的 profile 列表缓存
|
// 清除用户的 profile 列表缓存
|
||||||
s.cacheInv.OnCreate(ctx, s.cacheKeys.ProfileList(userID))
|
s.cacheInv.OnCreate(ctx, s.cacheKeys.ProfileList(userID))
|
||||||
|
|
||||||
@@ -98,7 +93,7 @@ func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Pro
|
|||||||
// 尝试从缓存获取
|
// 尝试从缓存获取
|
||||||
cacheKey := s.cacheKeys.Profile(uuid)
|
cacheKey := s.cacheKeys.Profile(uuid)
|
||||||
var profile model.Profile
|
var profile model.Profile
|
||||||
if ok, _ := s.cache.TryGet(ctx, cacheKey, &profile); ok {
|
if err := s.cache.Get(ctx, cacheKey, &profile); err == nil {
|
||||||
return &profile, nil
|
return &profile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +106,11 @@ func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Pro
|
|||||||
return nil, fmt.Errorf("查询档案失败: %w", err)
|
return nil, fmt.Errorf("查询档案失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存入缓存(异步)
|
// 存入缓存(异步,5分钟过期)
|
||||||
if profile2 != nil {
|
if profile2 != nil {
|
||||||
s.cache.SetAsync(context.Background(), cacheKey, profile2, s.cache.Policy.ProfileTTL)
|
go func() {
|
||||||
|
_ = s.cache.Set(context.Background(), cacheKey, profile2, 5*time.Minute)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return profile2, nil
|
return profile2, nil
|
||||||
@@ -123,7 +120,7 @@ func (s *profileService) GetByUserID(ctx context.Context, userID int64) ([]*mode
|
|||||||
// 尝试从缓存获取
|
// 尝试从缓存获取
|
||||||
cacheKey := s.cacheKeys.ProfileList(userID)
|
cacheKey := s.cacheKeys.ProfileList(userID)
|
||||||
var profiles []*model.Profile
|
var profiles []*model.Profile
|
||||||
if ok, _ := s.cache.TryGet(ctx, cacheKey, &profiles); ok {
|
if err := s.cache.Get(ctx, cacheKey, &profiles); err == nil {
|
||||||
return profiles, nil
|
return profiles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +130,11 @@ func (s *profileService) GetByUserID(ctx context.Context, userID int64) ([]*mode
|
|||||||
return nil, fmt.Errorf("查询档案列表失败: %w", err)
|
return nil, fmt.Errorf("查询档案列表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存入缓存(异步)
|
// 存入缓存(异步,3分钟过期)
|
||||||
if profiles != nil {
|
if profiles != nil {
|
||||||
s.cache.SetAsync(context.Background(), cacheKey, profiles, s.cache.Policy.ProfileListTTL)
|
go func() {
|
||||||
|
_ = s.cache.Set(context.Background(), cacheKey, profiles, 3*time.Minute)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return profiles, nil
|
return profiles, nil
|
||||||
@@ -215,34 +214,6 @@ func (s *profileService) Delete(ctx context.Context, uuid string, userID int64)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *profileService) SetActive(ctx context.Context, uuid string, userID int64) error {
|
|
||||||
// 获取档案并验证权限
|
|
||||||
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return ErrProfileNotFound
|
|
||||||
}
|
|
||||||
return fmt.Errorf("查询档案失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if profile.UserID != userID {
|
|
||||||
return ErrProfileNoPermission
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.profileRepo.SetActive(ctx, uuid, userID); err != nil {
|
|
||||||
return fmt.Errorf("设置活跃状态失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.profileRepo.UpdateLastUsedAt(ctx, uuid); err != nil {
|
|
||||||
return fmt.Errorf("更新使用时间失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除该用户所有 profile 的缓存(因为活跃状态改变了)
|
|
||||||
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.ProfilePattern(userID))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *profileService) CheckLimit(ctx context.Context, userID int64, maxProfiles int) error {
|
func (s *profileService) CheckLimit(ctx context.Context, userID int64, maxProfiles int) error {
|
||||||
count, err := s.profileRepo.CountByUserID(ctx, userID)
|
count, err := s.profileRepo.CountByUserID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -80,15 +80,6 @@ func TestProfileService_StatusValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProfileService_IsActiveDefault 测试Profile默认活跃状态
|
|
||||||
func TestProfileService_IsActiveDefault(t *testing.T) {
|
|
||||||
// 新创建的档案默认为活跃状态
|
|
||||||
isActive := true
|
|
||||||
if !isActive {
|
|
||||||
t.Error("新创建的Profile应该默认为活跃状态")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateProfile_PermissionCheck 测试更新Profile的权限检查逻辑
|
// TestUpdateProfile_PermissionCheck 测试更新Profile的权限检查逻辑
|
||||||
func TestUpdateProfile_PermissionCheck(t *testing.T) {
|
func TestUpdateProfile_PermissionCheck(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -191,38 +182,6 @@ func TestDeleteProfile_PermissionCheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSetActiveProfile_PermissionCheck 测试设置活跃Profile的权限检查
|
|
||||||
func TestSetActiveProfile_PermissionCheck(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
profileUserID int64
|
|
||||||
requestUserID int64
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "用户ID匹配,允许设置",
|
|
||||||
profileUserID: 1,
|
|
||||||
requestUserID: 1,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "用户ID不匹配,拒绝设置",
|
|
||||||
profileUserID: 1,
|
|
||||||
requestUserID: 2,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
hasError := tt.profileUserID != tt.requestUserID
|
|
||||||
if hasError != tt.wantErr {
|
|
||||||
t.Errorf("Permission check failed: got %v, want %v", hasError, tt.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCheckProfileLimit_Logic 测试Profile数量限制检查逻辑
|
// TestCheckProfileLimit_Logic 测试Profile数量限制检查逻辑
|
||||||
func TestCheckProfileLimit_Logic(t *testing.T) {
|
func TestCheckProfileLimit_Logic(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -642,8 +601,8 @@ func TestProfileServiceImpl_GetByUserID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProfileServiceImpl_Update_And_SetActive 测试 Update 与 SetActive
|
// TestProfileServiceImpl_Update 测试 Update
|
||||||
func TestProfileServiceImpl_Update_And_SetActive(t *testing.T) {
|
func TestProfileServiceImpl_Update(t *testing.T) {
|
||||||
profileRepo := NewMockProfileRepository()
|
profileRepo := NewMockProfileRepository()
|
||||||
userRepo := NewMockUserRepository()
|
userRepo := NewMockUserRepository()
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
@@ -686,16 +645,6 @@ func TestProfileServiceImpl_Update_And_SetActive(t *testing.T) {
|
|||||||
if _, err := svc.Update(ctx, "u1", 1, stringPtr("Duplicate"), nil, nil); err == nil {
|
if _, err := svc.Update(ctx, "u1", 1, stringPtr("Duplicate"), nil, nil); err == nil {
|
||||||
t.Fatalf("Update 在名称重复时应返回错误")
|
t.Fatalf("Update 在名称重复时应返回错误")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActive 正常
|
|
||||||
if err := svc.SetActive(ctx, "u1", 1); err != nil {
|
|
||||||
t.Fatalf("SetActive 正常情况失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetActive 无权限
|
|
||||||
if err := svc.SetActive(ctx, "u1", 2); err == nil {
|
|
||||||
t.Fatalf("SetActive 在无权限时应返回错误")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProfileServiceImpl_CheckLimit_And_GetByNames 测试 CheckLimit / GetByNames / GetByProfileName
|
// TestProfileServiceImpl_CheckLimit_And_GetByNames 测试 CheckLimit / GetByNames / GetByProfileName
|
||||||
|
|||||||
121
internal/service/skin_renderer/cape_renderer.go
Normal file
121
internal/service/skin_renderer/cape_renderer.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package skin_renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CapeRenderer 披风渲染器
|
||||||
|
type CapeRenderer struct{}
|
||||||
|
|
||||||
|
// NewCapeRenderer 创建披风渲染器
|
||||||
|
func NewCapeRenderer() *CapeRenderer {
|
||||||
|
return &CapeRenderer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render 渲染披风
|
||||||
|
// 披风纹理布局:
|
||||||
|
// - 正面: (1, 1) 到 (11, 17) - 10x16 像素
|
||||||
|
// - 背面: (12, 1) 到 (22, 17) - 10x16 像素
|
||||||
|
func (r *CapeRenderer) Render(capeData []byte, height int) (image.Image, error) {
|
||||||
|
// 解码披风图像
|
||||||
|
img, err := png.Decode(bytes.NewReader(capeData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := img.Bounds()
|
||||||
|
srcWidth := bounds.Dx()
|
||||||
|
srcHeight := bounds.Dy()
|
||||||
|
|
||||||
|
// 披风纹理可能是 64x32 或 22x17
|
||||||
|
// 标准披风正面区域
|
||||||
|
var frontX, frontY, frontW, frontH int
|
||||||
|
|
||||||
|
if srcWidth >= 64 && srcHeight >= 32 {
|
||||||
|
// 64x32 格式(Minecraft 1.8+)
|
||||||
|
// 正面: (1, 1) 到 (11, 17)
|
||||||
|
frontX = 1
|
||||||
|
frontY = 1
|
||||||
|
frontW = 10
|
||||||
|
frontH = 16
|
||||||
|
} else if srcWidth >= 22 && srcHeight >= 17 {
|
||||||
|
// 22x17 格式(旧版)
|
||||||
|
frontX = 1
|
||||||
|
frontY = 1
|
||||||
|
frontW = 10
|
||||||
|
frontH = 16
|
||||||
|
} else {
|
||||||
|
// 未知格式,直接缩放整个图像
|
||||||
|
return resizeImageBilinear(img, height*srcWidth/srcHeight, height), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取正面区域
|
||||||
|
front := image.NewRGBA(image.Rect(0, 0, frontW, frontH))
|
||||||
|
for y := 0; y < frontH; y++ {
|
||||||
|
for x := 0; x < frontW; x++ {
|
||||||
|
front.Set(x, y, img.At(bounds.Min.X+frontX+x, bounds.Min.Y+frontY+y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算输出尺寸,保持宽高比
|
||||||
|
outputWidth := height * frontW / frontH
|
||||||
|
if outputWidth < 1 {
|
||||||
|
outputWidth = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用最近邻缩放保持像素风格
|
||||||
|
return scaleNearest(front, outputWidth, height), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleNearest 最近邻缩放
|
||||||
|
func scaleNearest(src image.Image, width, height int) *image.RGBA {
|
||||||
|
bounds := src.Bounds()
|
||||||
|
srcW := bounds.Dx()
|
||||||
|
srcH := bounds.Dy()
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
srcX := bounds.Min.X + x*srcW/width
|
||||||
|
srcY := bounds.Min.Y + y*srcH/height
|
||||||
|
dst.Set(x, y, src.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// resizeImageBilinear 双线性插值缩放
|
||||||
|
func resizeImageBilinear(src image.Image, width, height int) *image.RGBA {
|
||||||
|
bounds := src.Bounds()
|
||||||
|
srcW := float64(bounds.Dx())
|
||||||
|
srcH := float64(bounds.Dy())
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
// 计算源图像中的位置
|
||||||
|
srcX := float64(x) * srcW / float64(width)
|
||||||
|
srcY := float64(y) * srcH / float64(height)
|
||||||
|
|
||||||
|
// 简单的最近邻(可以改进为双线性)
|
||||||
|
ix := int(srcX)
|
||||||
|
iy := int(srcY)
|
||||||
|
|
||||||
|
if ix >= bounds.Dx() {
|
||||||
|
ix = bounds.Dx() - 1
|
||||||
|
}
|
||||||
|
if iy >= bounds.Dy() {
|
||||||
|
iy = bounds.Dy() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
dst.Set(x, y, src.At(bounds.Min.X+ix, bounds.Min.Y+iy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dst
|
||||||
|
}
|
||||||
113
internal/service/skin_renderer/minecraft.go
Normal file
113
internal/service/skin_renderer/minecraft.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package skin_renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Minecraft 提供 Minecraft 皮肤渲染的入口方法
|
||||||
|
// 与 blessing/texture-renderer 的 Minecraft 类保持兼容
|
||||||
|
type Minecraft struct{}
|
||||||
|
|
||||||
|
// NewMinecraft 创建 Minecraft 渲染器实例
|
||||||
|
func NewMinecraft() *Minecraft {
|
||||||
|
return &Minecraft{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSkin 渲染完整皮肤预览(正面+背面)
|
||||||
|
// ratio: 缩放比例,默认 7.0
|
||||||
|
// isAlex: 是否为 Alex 模型(细手臂)
|
||||||
|
func (m *Minecraft) RenderSkin(skinData []byte, ratio float64, isAlex bool) (image.Image, error) {
|
||||||
|
vp := 15 // vertical padding
|
||||||
|
hp := 30 // horizontal padding
|
||||||
|
ip := 15 // internal padding
|
||||||
|
|
||||||
|
// 渲染正面(-45度)
|
||||||
|
frontRenderer := NewSkinRenderer(ratio, false, -45, -25)
|
||||||
|
front, err := frontRenderer.Render(skinData, isAlex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染背面(135度)
|
||||||
|
backRenderer := NewSkinRenderer(ratio, false, 135, -25)
|
||||||
|
back, err := backRenderer.Render(skinData, isAlex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
width := front.Bounds().Dx()
|
||||||
|
height := front.Bounds().Dy()
|
||||||
|
|
||||||
|
// 创建画布
|
||||||
|
canvas := createEmptyCanvas((hp+width+ip)*2, vp*2+height)
|
||||||
|
|
||||||
|
// 绘制背面(左侧)
|
||||||
|
draw.Draw(canvas, image.Rect(hp, vp, hp+width, vp+height), back, back.Bounds().Min, draw.Over)
|
||||||
|
|
||||||
|
// 绘制正面(右侧)
|
||||||
|
draw.Draw(canvas, image.Rect(hp+width+ip*2, vp, hp+width*2+ip*2, vp+height), front, front.Bounds().Min, draw.Over)
|
||||||
|
|
||||||
|
return canvas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderCape 渲染披风
|
||||||
|
// height: 输出高度
|
||||||
|
func (m *Minecraft) RenderCape(capeData []byte, height int) (image.Image, error) {
|
||||||
|
vp := 20 // vertical padding
|
||||||
|
hp := 40 // horizontal padding
|
||||||
|
|
||||||
|
renderer := NewCapeRenderer()
|
||||||
|
cape, err := renderer.Render(capeData, height)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
width := cape.Bounds().Dx()
|
||||||
|
capeHeight := cape.Bounds().Dy()
|
||||||
|
|
||||||
|
canvas := createEmptyCanvas(hp*2+width, vp*2+capeHeight)
|
||||||
|
draw.Draw(canvas, image.Rect(hp, vp, hp+width, vp+capeHeight), cape, cape.Bounds().Min, draw.Over)
|
||||||
|
|
||||||
|
return canvas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render2DAvatar 渲染 2D 头像
|
||||||
|
// ratio: 缩放比例,默认 15.0
|
||||||
|
func (m *Minecraft) Render2DAvatar(skinData []byte, ratio float64) (image.Image, error) {
|
||||||
|
renderer := NewSkinRendererFull(ratio, true, 0, 0, 0, 0, 0, 0, 0, true)
|
||||||
|
return renderer.Render(skinData, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render3DAvatar 渲染 3D 头像
|
||||||
|
// ratio: 缩放比例,默认 15.0
|
||||||
|
func (m *Minecraft) Render3DAvatar(skinData []byte, ratio float64) (image.Image, error) {
|
||||||
|
renderer := NewSkinRenderer(ratio, true, 45, -25)
|
||||||
|
return renderer.Render(skinData, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSkinWithAngle 渲染指定角度的皮肤
|
||||||
|
func (m *Minecraft) RenderSkinWithAngle(skinData []byte, ratio float64, isAlex bool, hRotation, vRotation float64) (image.Image, error) {
|
||||||
|
renderer := NewSkinRenderer(ratio, false, hRotation, vRotation)
|
||||||
|
return renderer.Render(skinData, isAlex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderHeadWithAngle 渲染指定角度的头像
|
||||||
|
func (m *Minecraft) RenderHeadWithAngle(skinData []byte, ratio float64, hRotation, vRotation float64) (image.Image, error) {
|
||||||
|
renderer := NewSkinRenderer(ratio, true, hRotation, vRotation)
|
||||||
|
return renderer.Render(skinData, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createEmptyCanvas 创建透明画布
|
||||||
|
func createEmptyCanvas(width, height int) *image.RGBA {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
// 填充透明背景
|
||||||
|
transparent := color.RGBA{0, 0, 0, 0}
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
img.SetRGBA(x, y, transparent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
95
internal/service/skin_renderer/point.go
Normal file
95
internal/service/skin_renderer/point.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// Package skin_renderer 实现 Minecraft 皮肤的 3D 渲染
|
||||||
|
// 移植自 blessing/texture-renderer
|
||||||
|
package skin_renderer
|
||||||
|
|
||||||
|
// Point 表示 3D 空间中的一个点
|
||||||
|
type Point struct {
|
||||||
|
// 原始坐标
|
||||||
|
originX, originY, originZ float64
|
||||||
|
// 投影后的坐标
|
||||||
|
destX, destY, destZ float64
|
||||||
|
// 是否已投影
|
||||||
|
isProjected bool
|
||||||
|
isPreProjected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPoint 创建一个新的 3D 点
|
||||||
|
func NewPoint(x, y, z float64) *Point {
|
||||||
|
return &Point{
|
||||||
|
originX: x,
|
||||||
|
originY: y,
|
||||||
|
originZ: z,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project 将 3D 点投影到 2D 平面
|
||||||
|
// 使用欧拉角旋转:alpha 为垂直旋转(X轴),omega 为水平旋转(Y轴)
|
||||||
|
func (p *Point) Project(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) {
|
||||||
|
x := p.originX
|
||||||
|
y := p.originY
|
||||||
|
z := p.originZ
|
||||||
|
|
||||||
|
// 3D 旋转投影公式
|
||||||
|
p.destX = x*cosOmega + z*sinOmega
|
||||||
|
p.destY = x*sinAlpha*sinOmega + y*cosAlpha - z*sinAlpha*cosOmega
|
||||||
|
p.destZ = -x*cosAlpha*sinOmega + y*sinAlpha + z*cosAlpha*cosOmega
|
||||||
|
|
||||||
|
p.isProjected = true
|
||||||
|
|
||||||
|
// 更新边界
|
||||||
|
if p.destX < *minX {
|
||||||
|
*minX = p.destX
|
||||||
|
}
|
||||||
|
if p.destX > *maxX {
|
||||||
|
*maxX = p.destX
|
||||||
|
}
|
||||||
|
if p.destY < *minY {
|
||||||
|
*minY = p.destY
|
||||||
|
}
|
||||||
|
if p.destY > *maxY {
|
||||||
|
*maxY = p.destY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreProject 预投影,用于部件独立旋转(如头部、手臂)
|
||||||
|
// dx, dy, dz 为旋转中心点
|
||||||
|
func (p *Point) PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega float64) {
|
||||||
|
if p.isPreProjected {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相对于旋转中心的坐标
|
||||||
|
x := p.originX - dx
|
||||||
|
y := p.originY - dy
|
||||||
|
z := p.originZ - dz
|
||||||
|
|
||||||
|
// 旋转后加回偏移
|
||||||
|
p.originX = x*cosOmega + z*sinOmega + dx
|
||||||
|
p.originY = x*sinAlpha*sinOmega + y*cosAlpha - z*sinAlpha*cosOmega + dy
|
||||||
|
p.originZ = -x*cosAlpha*sinOmega + y*sinAlpha + z*cosAlpha*cosOmega + dz
|
||||||
|
|
||||||
|
p.isPreProjected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDestCoord 获取投影后的坐标
|
||||||
|
func (p *Point) GetDestCoord() (x, y, z float64) {
|
||||||
|
return p.destX, p.destY, p.destZ
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOriginCoord 获取原始坐标
|
||||||
|
func (p *Point) GetOriginCoord() (x, y, z float64) {
|
||||||
|
return p.originX, p.originY, p.originZ
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsProjected 返回是否已投影
|
||||||
|
func (p *Point) IsProjected() bool {
|
||||||
|
return p.isProjected
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDepth 获取深度值(用于排序)
|
||||||
|
func (p *Point) GetDepth(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) float64 {
|
||||||
|
if !p.isProjected {
|
||||||
|
p.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, minX, maxX, minY, maxY)
|
||||||
|
}
|
||||||
|
return p.destZ
|
||||||
|
}
|
||||||
200
internal/service/skin_renderer/polygon.go
Normal file
200
internal/service/skin_renderer/polygon.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package skin_renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Polygon 表示一个四边形面片
|
||||||
|
type Polygon struct {
|
||||||
|
dots [4]*Point
|
||||||
|
color color.RGBA
|
||||||
|
isProjected bool
|
||||||
|
face string // 面的方向: "x", "y", "z"
|
||||||
|
faceDepth float64 // 面的深度
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPolygon 创建一个新的多边形
|
||||||
|
func NewPolygon(dots [4]*Point, c color.RGBA) *Polygon {
|
||||||
|
p := &Polygon{
|
||||||
|
dots: dots,
|
||||||
|
color: c,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定面的方向
|
||||||
|
x0, y0, z0 := dots[0].GetOriginCoord()
|
||||||
|
x1, y1, z1 := dots[1].GetOriginCoord()
|
||||||
|
x2, y2, z2 := dots[2].GetOriginCoord()
|
||||||
|
|
||||||
|
if x0 == x1 && x1 == x2 {
|
||||||
|
p.face = "x"
|
||||||
|
p.faceDepth = x0
|
||||||
|
} else if y0 == y1 && y1 == y2 {
|
||||||
|
p.face = "y"
|
||||||
|
p.faceDepth = y0
|
||||||
|
} else if z0 == z1 && z1 == z2 {
|
||||||
|
p.face = "z"
|
||||||
|
p.faceDepth = z0
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project 投影多边形的所有顶点
|
||||||
|
func (p *Polygon) Project(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) {
|
||||||
|
for _, dot := range p.dots {
|
||||||
|
if !dot.IsProjected() {
|
||||||
|
dot.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, minX, maxX, minY, maxY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.isProjected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreProject 预投影多边形的所有顶点
|
||||||
|
func (p *Polygon) PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega float64) {
|
||||||
|
for _, dot := range p.dots {
|
||||||
|
dot.PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsProjected 返回是否已投影
|
||||||
|
func (p *Polygon) IsProjected() bool {
|
||||||
|
return p.isProjected
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToImage 将多边形绘制到图像上
|
||||||
|
func (p *Polygon) AddToImage(img *image.RGBA, minX, minY, ratio float64) {
|
||||||
|
// 检查透明度,完全透明则跳过
|
||||||
|
if p.color.A == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取投影后的 2D 坐标
|
||||||
|
points := make([][2]float64, 4)
|
||||||
|
var coordX, coordY *float64
|
||||||
|
|
||||||
|
samePlanX := true
|
||||||
|
samePlanY := true
|
||||||
|
|
||||||
|
for i, dot := range p.dots {
|
||||||
|
x, y, _ := dot.GetDestCoord()
|
||||||
|
points[i] = [2]float64{
|
||||||
|
(x - minX) * ratio,
|
||||||
|
(y - minY) * ratio,
|
||||||
|
}
|
||||||
|
|
||||||
|
if coordX == nil {
|
||||||
|
coordX = &x
|
||||||
|
coordY = &y
|
||||||
|
} else {
|
||||||
|
if *coordX != x {
|
||||||
|
samePlanX = false
|
||||||
|
}
|
||||||
|
if *coordY != y {
|
||||||
|
samePlanY = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果所有点在同一平面(退化面),跳过
|
||||||
|
if samePlanX || samePlanY {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用扫描线算法填充多边形
|
||||||
|
fillPolygon(img, points, p.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillPolygon 使用扫描线算法填充四边形
|
||||||
|
func fillPolygon(img *image.RGBA, points [][2]float64, c color.RGBA) {
|
||||||
|
// 找到 Y 的范围
|
||||||
|
minY := points[0][1]
|
||||||
|
maxY := points[0][1]
|
||||||
|
for _, pt := range points {
|
||||||
|
if pt[1] < minY {
|
||||||
|
minY = pt[1]
|
||||||
|
}
|
||||||
|
if pt[1] > maxY {
|
||||||
|
maxY = pt[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := img.Bounds()
|
||||||
|
|
||||||
|
// 扫描每一行
|
||||||
|
for y := int(minY); y <= int(maxY); y++ {
|
||||||
|
if y < bounds.Min.Y || y >= bounds.Max.Y {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到这一行与多边形边的交点
|
||||||
|
var intersections []float64
|
||||||
|
n := len(points)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
j := (i + 1) % n
|
||||||
|
y1, y2 := points[i][1], points[j][1]
|
||||||
|
x1, x2 := points[i][0], points[j][0]
|
||||||
|
|
||||||
|
// 检查这条边是否与当前扫描线相交
|
||||||
|
if (y1 <= float64(y) && y2 > float64(y)) || (y2 <= float64(y) && y1 > float64(y)) {
|
||||||
|
// 计算交点的 X 坐标
|
||||||
|
t := (float64(y) - y1) / (y2 - y1)
|
||||||
|
x := x1 + t*(x2-x1)
|
||||||
|
intersections = append(intersections, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序交点
|
||||||
|
for i := 0; i < len(intersections)-1; i++ {
|
||||||
|
for j := i + 1; j < len(intersections); j++ {
|
||||||
|
if intersections[i] > intersections[j] {
|
||||||
|
intersections[i], intersections[j] = intersections[j], intersections[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充交点之间的像素
|
||||||
|
for i := 0; i+1 < len(intersections); i += 2 {
|
||||||
|
xStart := int(intersections[i])
|
||||||
|
xEnd := int(intersections[i+1])
|
||||||
|
|
||||||
|
for x := xStart; x <= xEnd; x++ {
|
||||||
|
if x >= bounds.Min.X && x < bounds.Max.X {
|
||||||
|
// Alpha 混合
|
||||||
|
if c.A == 255 {
|
||||||
|
img.SetRGBA(x, y, c)
|
||||||
|
} else {
|
||||||
|
existing := img.RGBAAt(x, y)
|
||||||
|
blended := alphaBlend(existing, c)
|
||||||
|
img.SetRGBA(x, y, blended)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// alphaBlend 执行 Alpha 混合
|
||||||
|
func alphaBlend(dst, src color.RGBA) color.RGBA {
|
||||||
|
if src.A == 0 {
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
if src.A == 255 {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
srcA := float64(src.A) / 255.0
|
||||||
|
dstA := float64(dst.A) / 255.0
|
||||||
|
outA := srcA + dstA*(1-srcA)
|
||||||
|
|
||||||
|
if outA == 0 {
|
||||||
|
return color.RGBA{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return color.RGBA{
|
||||||
|
R: uint8((float64(src.R)*srcA + float64(dst.R)*dstA*(1-srcA)) / outA),
|
||||||
|
G: uint8((float64(src.G)*srcA + float64(dst.G)*dstA*(1-srcA)) / outA),
|
||||||
|
B: uint8((float64(src.B)*srcA + float64(dst.B)*dstA*(1-srcA)) / outA),
|
||||||
|
A: uint8(outA * 255),
|
||||||
|
}
|
||||||
|
}
|
||||||
1080
internal/service/skin_renderer/polygons.go
Normal file
1080
internal/service/skin_renderer/polygons.go
Normal file
File diff suppressed because it is too large
Load Diff
591
internal/service/skin_renderer/renderer.go
Normal file
591
internal/service/skin_renderer/renderer.go
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
package skin_renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SkinRenderer 皮肤渲染器
|
||||||
|
type SkinRenderer struct {
|
||||||
|
playerSkin image.Image
|
||||||
|
isNewSkinType bool
|
||||||
|
isAlex bool
|
||||||
|
hdRatio int
|
||||||
|
|
||||||
|
// 旋转参数
|
||||||
|
ratio float64
|
||||||
|
headOnly bool
|
||||||
|
hR float64 // 水平旋转角度
|
||||||
|
vR float64 // 垂直旋转角度
|
||||||
|
hrh float64 // 头部水平旋转
|
||||||
|
vrll float64 // 左腿垂直旋转
|
||||||
|
vrrl float64 // 右腿垂直旋转
|
||||||
|
vrla float64 // 左臂垂直旋转
|
||||||
|
vrra float64 // 右臂垂直旋转
|
||||||
|
layers bool // 是否渲染第二层
|
||||||
|
|
||||||
|
// 计算后的三角函数值
|
||||||
|
cosAlpha, sinAlpha float64
|
||||||
|
cosOmega, sinOmega float64
|
||||||
|
|
||||||
|
// 边界
|
||||||
|
minX, maxX, minY, maxY float64
|
||||||
|
|
||||||
|
// 各部件的旋转角度
|
||||||
|
membersAngles map[string]angleSet
|
||||||
|
|
||||||
|
// 可见面
|
||||||
|
visibleFaces map[string]faceVisibility
|
||||||
|
frontFaces []string
|
||||||
|
backFaces []string
|
||||||
|
|
||||||
|
// 多边形
|
||||||
|
polygons map[string]map[string][]*Polygon
|
||||||
|
}
|
||||||
|
|
||||||
|
type angleSet struct {
|
||||||
|
cosAlpha, sinAlpha float64
|
||||||
|
cosOmega, sinOmega float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type faceVisibility struct {
|
||||||
|
front []string
|
||||||
|
back []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var allFaces = []string{"back", "right", "top", "front", "left", "bottom"}
|
||||||
|
|
||||||
|
// NewSkinRenderer 创建皮肤渲染器
|
||||||
|
func NewSkinRenderer(ratio float64, headOnly bool, horizontalRotation, verticalRotation float64) *SkinRenderer {
|
||||||
|
return &SkinRenderer{
|
||||||
|
ratio: ratio,
|
||||||
|
headOnly: headOnly,
|
||||||
|
hR: horizontalRotation,
|
||||||
|
vR: verticalRotation,
|
||||||
|
hrh: 0,
|
||||||
|
vrll: 0,
|
||||||
|
vrrl: 0,
|
||||||
|
vrla: 0,
|
||||||
|
vrra: 0,
|
||||||
|
layers: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSkinRendererFull 创建带完整参数的皮肤渲染器
|
||||||
|
func NewSkinRendererFull(ratio float64, headOnly bool, hR, vR, hrh, vrll, vrrl, vrla, vrra float64, layers bool) *SkinRenderer {
|
||||||
|
return &SkinRenderer{
|
||||||
|
ratio: ratio,
|
||||||
|
headOnly: headOnly,
|
||||||
|
hR: hR,
|
||||||
|
vR: vR,
|
||||||
|
hrh: hrh,
|
||||||
|
vrll: vrll,
|
||||||
|
vrrl: vrrl,
|
||||||
|
vrla: vrla,
|
||||||
|
vrra: vrra,
|
||||||
|
layers: layers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render 渲染皮肤
|
||||||
|
func (r *SkinRenderer) Render(skinData []byte, isAlex bool) (image.Image, error) {
|
||||||
|
// 解码皮肤图像
|
||||||
|
img, err := png.Decode(bytes.NewReader(skinData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.playerSkin = img
|
||||||
|
r.isAlex = isAlex
|
||||||
|
|
||||||
|
// 计算 HD 比例
|
||||||
|
sourceWidth := img.Bounds().Dx()
|
||||||
|
sourceHeight := img.Bounds().Dy()
|
||||||
|
|
||||||
|
// 防止内存溢出,限制最大尺寸
|
||||||
|
if sourceWidth > 256 {
|
||||||
|
r.playerSkin = resizeImage(img, 256, sourceHeight*256/sourceWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.hdRatio = r.playerSkin.Bounds().Dx() / 64
|
||||||
|
|
||||||
|
// 检查是否为新版皮肤格式(64x64)
|
||||||
|
if r.playerSkin.Bounds().Dx() == r.playerSkin.Bounds().Dy() {
|
||||||
|
r.isNewSkinType = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 RGBA
|
||||||
|
r.playerSkin = convertToRGBA(r.playerSkin)
|
||||||
|
|
||||||
|
// 处理背景透明
|
||||||
|
r.makeBackgroundTransparent()
|
||||||
|
|
||||||
|
// 计算角度
|
||||||
|
r.calculateAngles()
|
||||||
|
|
||||||
|
// 确定可见面
|
||||||
|
r.facesDetermination()
|
||||||
|
|
||||||
|
// 生成多边形
|
||||||
|
r.generatePolygons()
|
||||||
|
|
||||||
|
// 部件旋转
|
||||||
|
r.memberRotation()
|
||||||
|
|
||||||
|
// 创建投影
|
||||||
|
r.createProjectionPlan()
|
||||||
|
|
||||||
|
// 渲染图像
|
||||||
|
return r.displayImage(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeBackgroundTransparent 处理背景透明
|
||||||
|
func (r *SkinRenderer) makeBackgroundTransparent() {
|
||||||
|
rgba, ok := r.playerSkin.(*image.RGBA)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查左上角 8x8 区域是否为纯色
|
||||||
|
var tempColor color.RGBA
|
||||||
|
needRemove := true
|
||||||
|
first := true
|
||||||
|
|
||||||
|
for y := 0; y < 8; y++ {
|
||||||
|
for x := 0; x < 8; x++ {
|
||||||
|
c := rgba.RGBAAt(x, y)
|
||||||
|
|
||||||
|
// 如果已有透明度,不需要处理
|
||||||
|
if c.A < 128 {
|
||||||
|
needRemove = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if first {
|
||||||
|
tempColor = c
|
||||||
|
first = false
|
||||||
|
} else if c != tempColor {
|
||||||
|
needRemove = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !needRemove {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !needRemove {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将该颜色设为透明
|
||||||
|
bounds := rgba.Bounds()
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
c := rgba.RGBAAt(x, y)
|
||||||
|
if c.R == tempColor.R && c.G == tempColor.G && c.B == tempColor.B {
|
||||||
|
rgba.SetRGBA(x, y, color.RGBA{0, 0, 0, 0})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateAngles 计算旋转角度
|
||||||
|
func (r *SkinRenderer) calculateAngles() {
|
||||||
|
// 转换为弧度
|
||||||
|
alpha := r.vR * math.Pi / 180
|
||||||
|
omega := r.hR * math.Pi / 180
|
||||||
|
|
||||||
|
r.cosAlpha = math.Cos(alpha)
|
||||||
|
r.sinAlpha = math.Sin(alpha)
|
||||||
|
r.cosOmega = math.Cos(omega)
|
||||||
|
r.sinOmega = math.Sin(omega)
|
||||||
|
|
||||||
|
r.membersAngles = make(map[string]angleSet)
|
||||||
|
|
||||||
|
// 躯干不旋转
|
||||||
|
r.membersAngles["torso"] = angleSet{
|
||||||
|
cosAlpha: 1, sinAlpha: 0,
|
||||||
|
cosOmega: 1, sinOmega: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头部旋转
|
||||||
|
omegaHead := r.hrh * math.Pi / 180
|
||||||
|
r.membersAngles["head"] = angleSet{
|
||||||
|
cosAlpha: 1, sinAlpha: 0,
|
||||||
|
cosOmega: math.Cos(omegaHead), sinOmega: math.Sin(omegaHead),
|
||||||
|
}
|
||||||
|
r.membersAngles["helmet"] = r.membersAngles["head"]
|
||||||
|
|
||||||
|
// 右臂旋转
|
||||||
|
alphaRightArm := r.vrra * math.Pi / 180
|
||||||
|
r.membersAngles["rightArm"] = angleSet{
|
||||||
|
cosAlpha: math.Cos(alphaRightArm), sinAlpha: math.Sin(alphaRightArm),
|
||||||
|
cosOmega: 1, sinOmega: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左臂旋转
|
||||||
|
alphaLeftArm := r.vrla * math.Pi / 180
|
||||||
|
r.membersAngles["leftArm"] = angleSet{
|
||||||
|
cosAlpha: math.Cos(alphaLeftArm), sinAlpha: math.Sin(alphaLeftArm),
|
||||||
|
cosOmega: 1, sinOmega: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右腿旋转
|
||||||
|
alphaRightLeg := r.vrrl * math.Pi / 180
|
||||||
|
r.membersAngles["rightLeg"] = angleSet{
|
||||||
|
cosAlpha: math.Cos(alphaRightLeg), sinAlpha: math.Sin(alphaRightLeg),
|
||||||
|
cosOmega: 1, sinOmega: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左腿旋转
|
||||||
|
alphaLeftLeg := r.vrll * math.Pi / 180
|
||||||
|
r.membersAngles["leftLeg"] = angleSet{
|
||||||
|
cosAlpha: math.Cos(alphaLeftLeg), sinAlpha: math.Sin(alphaLeftLeg),
|
||||||
|
cosOmega: 1, sinOmega: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
r.minX, r.maxX = 0, 0
|
||||||
|
r.minY, r.maxY = 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// facesDetermination 确定可见面
|
||||||
|
func (r *SkinRenderer) facesDetermination() {
|
||||||
|
r.visibleFaces = make(map[string]faceVisibility)
|
||||||
|
|
||||||
|
parts := []string{"head", "torso", "rightArm", "leftArm", "rightLeg", "leftLeg"}
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
angles := r.membersAngles[part]
|
||||||
|
|
||||||
|
// 创建测试立方体点
|
||||||
|
cubePoints := r.createCubePoints()
|
||||||
|
|
||||||
|
var maxDepthPoint *Point
|
||||||
|
var maxDepthFaces []string
|
||||||
|
|
||||||
|
for _, cp := range cubePoints {
|
||||||
|
point := cp.point
|
||||||
|
point.PreProject(0, 0, 0, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||||
|
point.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY)
|
||||||
|
|
||||||
|
if maxDepthPoint == nil {
|
||||||
|
maxDepthPoint = point
|
||||||
|
maxDepthFaces = cp.faces
|
||||||
|
} else {
|
||||||
|
_, _, z1 := maxDepthPoint.GetDestCoord()
|
||||||
|
_, _, z2 := point.GetDestCoord()
|
||||||
|
if z1 > z2 {
|
||||||
|
maxDepthPoint = point
|
||||||
|
maxDepthFaces = cp.faces
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.visibleFaces[part] = faceVisibility{
|
||||||
|
back: maxDepthFaces,
|
||||||
|
front: diffFaces(allFaces, maxDepthFaces),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定全局前后面
|
||||||
|
cubePoints := r.createCubePoints()
|
||||||
|
var maxDepthPoint *Point
|
||||||
|
var maxDepthFaces []string
|
||||||
|
|
||||||
|
for _, cp := range cubePoints {
|
||||||
|
point := cp.point
|
||||||
|
point.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY)
|
||||||
|
|
||||||
|
if maxDepthPoint == nil {
|
||||||
|
maxDepthPoint = point
|
||||||
|
maxDepthFaces = cp.faces
|
||||||
|
} else {
|
||||||
|
_, _, z1 := maxDepthPoint.GetDestCoord()
|
||||||
|
_, _, z2 := point.GetDestCoord()
|
||||||
|
if z1 > z2 {
|
||||||
|
maxDepthPoint = point
|
||||||
|
maxDepthFaces = cp.faces
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.backFaces = maxDepthFaces
|
||||||
|
r.frontFaces = diffFaces(allFaces, maxDepthFaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
type cubePoint struct {
|
||||||
|
point *Point
|
||||||
|
faces []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SkinRenderer) createCubePoints() []cubePoint {
|
||||||
|
return []cubePoint{
|
||||||
|
{NewPoint(0, 0, 0), []string{"back", "right", "top"}},
|
||||||
|
{NewPoint(0, 0, 1), []string{"front", "right", "top"}},
|
||||||
|
{NewPoint(0, 1, 0), []string{"back", "right", "bottom"}},
|
||||||
|
{NewPoint(0, 1, 1), []string{"front", "right", "bottom"}},
|
||||||
|
{NewPoint(1, 0, 0), []string{"back", "left", "top"}},
|
||||||
|
{NewPoint(1, 0, 1), []string{"front", "left", "top"}},
|
||||||
|
{NewPoint(1, 1, 0), []string{"back", "left", "bottom"}},
|
||||||
|
{NewPoint(1, 1, 1), []string{"front", "left", "bottom"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffFaces(all, exclude []string) []string {
|
||||||
|
excludeMap := make(map[string]bool)
|
||||||
|
for _, f := range exclude {
|
||||||
|
excludeMap[f] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
for _, f := range all {
|
||||||
|
if !excludeMap[f] {
|
||||||
|
result = append(result, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(slice []string, item string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if s == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberRotation 部件旋转
|
||||||
|
func (r *SkinRenderer) memberRotation() {
|
||||||
|
hd := float64(r.hdRatio)
|
||||||
|
|
||||||
|
// 头部和头盔旋转
|
||||||
|
angles := r.membersAngles["head"]
|
||||||
|
for _, face := range r.polygons["head"] {
|
||||||
|
for _, poly := range face {
|
||||||
|
poly.PreProject(4*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, face := range r.polygons["helmet"] {
|
||||||
|
for _, poly := range face {
|
||||||
|
poly.PreProject(4*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.headOnly {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右臂旋转
|
||||||
|
angles = r.membersAngles["rightArm"]
|
||||||
|
for _, face := range r.polygons["rightArm"] {
|
||||||
|
for _, poly := range face {
|
||||||
|
poly.PreProject(-2*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左臂旋转
|
||||||
|
angles = r.membersAngles["leftArm"]
|
||||||
|
for _, face := range r.polygons["leftArm"] {
|
||||||
|
for _, poly := range face {
|
||||||
|
poly.PreProject(10*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右腿旋转
|
||||||
|
angles = r.membersAngles["rightLeg"]
|
||||||
|
zOffset := 4 * hd
|
||||||
|
if angles.sinAlpha < 0 {
|
||||||
|
zOffset = 0
|
||||||
|
}
|
||||||
|
for _, face := range r.polygons["rightLeg"] {
|
||||||
|
for _, poly := range face {
|
||||||
|
poly.PreProject(2*hd, 20*hd, zOffset, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左腿旋转
|
||||||
|
angles = r.membersAngles["leftLeg"]
|
||||||
|
zOffset = 4 * hd
|
||||||
|
if angles.sinAlpha < 0 {
|
||||||
|
zOffset = 0
|
||||||
|
}
|
||||||
|
for _, face := range r.polygons["leftLeg"] {
|
||||||
|
for _, poly := range face {
|
||||||
|
poly.PreProject(6*hd, 20*hd, zOffset, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createProjectionPlan 创建投影
|
||||||
|
func (r *SkinRenderer) createProjectionPlan() {
|
||||||
|
for _, piece := range r.polygons {
|
||||||
|
for _, face := range piece {
|
||||||
|
for _, poly := range face {
|
||||||
|
if !poly.IsProjected() {
|
||||||
|
poly.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayImage 渲染最终图像
|
||||||
|
func (r *SkinRenderer) displayImage() image.Image {
|
||||||
|
width := r.maxX - r.minX
|
||||||
|
height := r.maxY - r.minY
|
||||||
|
ratio := r.ratio * 2
|
||||||
|
|
||||||
|
srcWidth := int(ratio*width) + 1
|
||||||
|
srcHeight := int(ratio*height) + 1
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, srcWidth, srcHeight))
|
||||||
|
|
||||||
|
// 按深度顺序绘制
|
||||||
|
displayOrder := r.getDisplayOrder()
|
||||||
|
|
||||||
|
for _, order := range displayOrder {
|
||||||
|
for piece, faces := range order {
|
||||||
|
for _, face := range faces {
|
||||||
|
if polys, ok := r.polygons[piece][face]; ok {
|
||||||
|
for _, poly := range polys {
|
||||||
|
poly.AddToImage(img, r.minX, r.minY, ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抗锯齿:2x 渲染后缩小
|
||||||
|
realWidth := srcWidth / 2
|
||||||
|
realHeight := srcHeight / 2
|
||||||
|
destImg := resizeImage(img, realWidth, realHeight)
|
||||||
|
|
||||||
|
return destImg
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDisplayOrder 获取绘制顺序
|
||||||
|
func (r *SkinRenderer) getDisplayOrder() []map[string][]string {
|
||||||
|
var displayOrder []map[string][]string
|
||||||
|
|
||||||
|
if contains(r.frontFaces, "top") {
|
||||||
|
if contains(r.frontFaces, "right") {
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
|
||||||
|
} else {
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
|
||||||
|
}
|
||||||
|
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"helmet": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"head": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"head": r.visibleFaces["head"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"helmet": r.visibleFaces["head"].front})
|
||||||
|
} else {
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"helmet": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"head": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"head": r.visibleFaces["head"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"helmet": r.visibleFaces["head"].front})
|
||||||
|
|
||||||
|
if contains(r.frontFaces, "right") {
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
|
||||||
|
} else {
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
|
||||||
|
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助函数
|
||||||
|
|
||||||
|
func convertToRGBA(img image.Image) *image.RGBA {
|
||||||
|
if rgba, ok := img.(*image.RGBA); ok {
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := img.Bounds()
|
||||||
|
rgba := image.NewRGBA(bounds)
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
rgba.Set(x, y, img.At(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizeImage(img image.Image, width, height int) *image.RGBA {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
srcW := bounds.Dx()
|
||||||
|
srcH := bounds.Dy()
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
srcX := bounds.Min.X + x*srcW/width
|
||||||
|
srcY := bounds.Min.Y + y*srcH/height
|
||||||
|
dst.Set(x, y, img.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPixelColor 从皮肤图像获取像素颜色
|
||||||
|
func (r *SkinRenderer) getPixelColor(x, y int) color.RGBA {
|
||||||
|
if x < 0 || y < 0 {
|
||||||
|
return color.RGBA{}
|
||||||
|
}
|
||||||
|
bounds := r.playerSkin.Bounds()
|
||||||
|
if x >= bounds.Dx() || y >= bounds.Dy() {
|
||||||
|
return color.RGBA{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c := r.playerSkin.At(bounds.Min.X+x, bounds.Min.Y+y)
|
||||||
|
r32, g32, b32, a32 := c.RGBA()
|
||||||
|
return color.RGBA{
|
||||||
|
R: uint8(r32 >> 8),
|
||||||
|
G: uint8(g32 >> 8),
|
||||||
|
B: uint8(b32 >> 8),
|
||||||
|
A: uint8(a32 >> 8),
|
||||||
|
}
|
||||||
|
}
|
||||||
203
internal/service/skin_renderer/renderer_test.go
Normal file
203
internal/service/skin_renderer/renderer_test.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package skin_renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestSkin 创建一个测试用的 64x64 皮肤图像
|
||||||
|
func createTestSkin() []byte {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||||
|
|
||||||
|
// 填充一些测试颜色
|
||||||
|
// 头部区域 (8,8) - (16,16)
|
||||||
|
for y := 8; y < 16; y++ {
|
||||||
|
for x := 8; x < 16; x++ {
|
||||||
|
img.Set(x, y, image.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 躯干区域 (20,20) - (28,32)
|
||||||
|
for y := 20; y < 32; y++ {
|
||||||
|
for x := 20; x < 28; x++ {
|
||||||
|
img.Set(x, y, image.Black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码为 PNG
|
||||||
|
f, _ := os.CreateTemp("", "test_skin_*.png")
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
png.Encode(f, img)
|
||||||
|
f.Seek(0, 0)
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(f.Name())
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkinRenderer_Render(t *testing.T) {
|
||||||
|
skinData := createTestSkin()
|
||||||
|
if len(skinData) == 0 {
|
||||||
|
t.Skip("无法创建测试皮肤")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer := NewSkinRenderer(7.0, false, -45, -25)
|
||||||
|
result, err := renderer.Render(skinData, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("渲染失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := result.Bounds()
|
||||||
|
if bounds.Dx() == 0 || bounds.Dy() == 0 {
|
||||||
|
t.Error("渲染结果尺寸为零")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkinRenderer_RenderHeadOnly(t *testing.T) {
|
||||||
|
skinData := createTestSkin()
|
||||||
|
if len(skinData) == 0 {
|
||||||
|
t.Skip("无法创建测试皮肤")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer := NewSkinRenderer(15.0, true, 45, -25)
|
||||||
|
result, err := renderer.Render(skinData, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("渲染头像失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := result.Bounds()
|
||||||
|
t.Logf("头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinecraft_RenderSkin(t *testing.T) {
|
||||||
|
skinData := createTestSkin()
|
||||||
|
if len(skinData) == 0 {
|
||||||
|
t.Skip("无法创建测试皮肤")
|
||||||
|
}
|
||||||
|
|
||||||
|
mc := NewMinecraft()
|
||||||
|
result, err := mc.RenderSkin(skinData, 7.0, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderSkin 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := result.Bounds()
|
||||||
|
t.Logf("完整皮肤渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinecraft_Render2DAvatar(t *testing.T) {
|
||||||
|
skinData := createTestSkin()
|
||||||
|
if len(skinData) == 0 {
|
||||||
|
t.Skip("无法创建测试皮肤")
|
||||||
|
}
|
||||||
|
|
||||||
|
mc := NewMinecraft()
|
||||||
|
result, err := mc.Render2DAvatar(skinData, 15.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render2DAvatar 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := result.Bounds()
|
||||||
|
t.Logf("2D头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinecraft_Render3DAvatar(t *testing.T) {
|
||||||
|
skinData := createTestSkin()
|
||||||
|
if len(skinData) == 0 {
|
||||||
|
t.Skip("无法创建测试皮肤")
|
||||||
|
}
|
||||||
|
|
||||||
|
mc := NewMinecraft()
|
||||||
|
result, err := mc.Render3DAvatar(skinData, 15.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render3DAvatar 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("渲染结果为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := result.Bounds()
|
||||||
|
t.Logf("3D头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoint_Project(t *testing.T) {
|
||||||
|
p := NewPoint(1, 2, 3)
|
||||||
|
|
||||||
|
var minX, maxX, minY, maxY float64
|
||||||
|
|
||||||
|
// 测试 45 度旋转
|
||||||
|
cosAlpha := 0.9063077870366499 // cos(-25°)
|
||||||
|
sinAlpha := -0.42261826174069944 // sin(-25°)
|
||||||
|
cosOmega := 0.7071067811865476 // cos(45°)
|
||||||
|
sinOmega := 0.7071067811865476 // sin(45°)
|
||||||
|
|
||||||
|
p.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, &minX, &maxX, &minY, &maxY)
|
||||||
|
|
||||||
|
x, y, z := p.GetDestCoord()
|
||||||
|
t.Logf("投影结果: x=%.2f, y=%.2f, z=%.2f", x, y, z)
|
||||||
|
|
||||||
|
if !p.IsProjected() {
|
||||||
|
t.Error("点应该标记为已投影")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolygon_AddToImage(t *testing.T) {
|
||||||
|
// 创建一个简单的正方形多边形
|
||||||
|
p1 := NewPoint(0, 0, 0)
|
||||||
|
p2 := NewPoint(10, 0, 0)
|
||||||
|
p3 := NewPoint(10, 10, 0)
|
||||||
|
p4 := NewPoint(0, 10, 0)
|
||||||
|
|
||||||
|
var minX, maxX, minY, maxY float64
|
||||||
|
p1.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
|
||||||
|
p2.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
|
||||||
|
p3.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
|
||||||
|
p4.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
|
||||||
|
|
||||||
|
poly := NewPolygon([4]*Point{p1, p2, p3, p4}, color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
|
||||||
|
poly.AddToImage(img, minX, minY, 5.0)
|
||||||
|
|
||||||
|
// 检查是否有像素被绘制
|
||||||
|
hasPixels := false
|
||||||
|
for y := 0; y < 100; y++ {
|
||||||
|
for x := 0; x < 100; x++ {
|
||||||
|
c := img.RGBAAt(x, y)
|
||||||
|
if c.A > 0 {
|
||||||
|
hasPixels = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasPixels {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasPixels {
|
||||||
|
t.Error("多边形应该在图像上绘制了像素")
|
||||||
|
}
|
||||||
|
}
|
||||||
808
internal/service/texture_render_service.go
Normal file
808
internal/service/texture_render_service.go
Normal file
@@ -0,0 +1,808 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/repository"
|
||||||
|
"carrotskin/internal/service/skin_renderer"
|
||||||
|
"carrotskin/pkg/database"
|
||||||
|
"carrotskin/pkg/storage"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chai2010/webp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// textureRenderService TextureRenderService的实现
|
||||||
|
type textureRenderService struct {
|
||||||
|
textureRepo repository.TextureRepository
|
||||||
|
storage *storage.StorageClient
|
||||||
|
cache *database.CacheManager
|
||||||
|
cacheKeys *database.CacheKeyBuilder
|
||||||
|
logger *zap.Logger
|
||||||
|
minecraft *skin_renderer.Minecraft // 3D 渲染器
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTextureRenderService 创建TextureRenderService实例
|
||||||
|
func NewTextureRenderService(
|
||||||
|
textureRepo repository.TextureRepository,
|
||||||
|
storageClient *storage.StorageClient,
|
||||||
|
cacheManager *database.CacheManager,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) TextureRenderService {
|
||||||
|
return &textureRenderService{
|
||||||
|
textureRepo: textureRepo,
|
||||||
|
storage: storageClient,
|
||||||
|
cache: cacheManager,
|
||||||
|
cacheKeys: database.NewCacheKeyBuilder(""),
|
||||||
|
logger: logger,
|
||||||
|
minecraft: skin_renderer.NewMinecraft(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderTexture 渲染纹理为预览图
|
||||||
|
func (s *textureRenderService) RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error) {
|
||||||
|
// 参数验证
|
||||||
|
if size <= 0 || size > 2048 {
|
||||||
|
return nil, errors.New("渲染尺寸必须在1到2048之间")
|
||||||
|
}
|
||||||
|
contentType, err := normalizeFormat(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查缓存(包含格式)
|
||||||
|
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderType, format), size)
|
||||||
|
var cached RenderResult
|
||||||
|
if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" {
|
||||||
|
return &cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取纹理信息
|
||||||
|
texture, err := s.textureRepo.FindByID(ctx, textureID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取纹理失败: %w", err)
|
||||||
|
}
|
||||||
|
if texture == nil {
|
||||||
|
return nil, errors.New("纹理不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从对象存储获取纹理文件
|
||||||
|
textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("下载纹理失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染纹理
|
||||||
|
renderedImage, _, err := s.RenderTextureFromData(ctx, textureData, renderType, size, format, texture.IsSlim)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("渲染纹理失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存渲染结果到对象存储
|
||||||
|
result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, renderType, size, format, renderedImage, contentType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("保存渲染结果失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若源对象有元信息,透传 LastModified/ETag 作为参考
|
||||||
|
if srcInfo != nil {
|
||||||
|
if result.LastModified.IsZero() {
|
||||||
|
result.LastModified = srcInfo.LastModified
|
||||||
|
}
|
||||||
|
if result.ETag == "" {
|
||||||
|
result.ETag = srcInfo.ETag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存结果(1小时)
|
||||||
|
if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil {
|
||||||
|
s.logger.Warn("缓存渲染结果失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderAvatar 渲染头像(支持2D/3D模式)
|
||||||
|
func (s *textureRenderService) RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error) {
|
||||||
|
if size <= 0 || size > 1024 {
|
||||||
|
return nil, errors.New("头像渲染尺寸必须在1到1024之间")
|
||||||
|
}
|
||||||
|
contentType, err := normalizeFormat(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
renderKey := fmt.Sprintf("avatar-%s", mode)
|
||||||
|
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderKey, format), size)
|
||||||
|
var cached RenderResult
|
||||||
|
if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" {
|
||||||
|
return &cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
texture, err := s.textureRepo.FindByID(ctx, textureID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取纹理失败: %w", err)
|
||||||
|
}
|
||||||
|
if texture == nil {
|
||||||
|
return nil, errors.New("纹理不存在")
|
||||||
|
}
|
||||||
|
if texture.Type != model.TextureTypeSkin {
|
||||||
|
return nil, errors.New("仅皮肤纹理支持头像渲染")
|
||||||
|
}
|
||||||
|
|
||||||
|
textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("下载纹理失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用新的 3D 渲染器
|
||||||
|
var rendered image.Image
|
||||||
|
switch mode {
|
||||||
|
case AvatarMode3D:
|
||||||
|
// 使用 Blessing Skin 风格的 3D 头像渲染
|
||||||
|
ratio := float64(size) / 15.0 // 基准比例
|
||||||
|
rendered, err = s.minecraft.Render3DAvatar(textureData, ratio)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("3D头像渲染失败,回退到2D", zap.Error(err))
|
||||||
|
img, decErr := png.Decode(bytes.NewReader(textureData))
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, fmt.Errorf("解码PNG失败: %w", decErr)
|
||||||
|
}
|
||||||
|
rendered = s.renderHeadView(img, size)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// 2D 头像使用新渲染器
|
||||||
|
ratio := float64(size) / 15.0
|
||||||
|
rendered, err = s.minecraft.Render2DAvatar(textureData, ratio)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("2D头像渲染失败,回退到旧方法", zap.Error(err))
|
||||||
|
img, decErr := png.Decode(bytes.NewReader(textureData))
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, fmt.Errorf("解码PNG失败: %w", decErr)
|
||||||
|
}
|
||||||
|
rendered = s.renderHeadView(img, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeImage(rendered, format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("编码渲染头像失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, RenderType(renderKey), size, format, encoded, contentType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("保存头像渲染失败: %w", err)
|
||||||
|
}
|
||||||
|
if srcInfo != nil && result.LastModified.IsZero() {
|
||||||
|
result.LastModified = srcInfo.LastModified
|
||||||
|
}
|
||||||
|
if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil {
|
||||||
|
s.logger.Warn("缓存头像渲染失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderCape 渲染披风
|
||||||
|
func (s *textureRenderService) RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) {
|
||||||
|
if size <= 0 || size > 2048 {
|
||||||
|
return nil, errors.New("披风渲染尺寸必须在1到2048之间")
|
||||||
|
}
|
||||||
|
contentType, err := normalizeFormat(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("cape:%s", format), size)
|
||||||
|
var cached RenderResult
|
||||||
|
if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" {
|
||||||
|
return &cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
texture, err := s.textureRepo.FindByID(ctx, textureID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取纹理失败: %w", err)
|
||||||
|
}
|
||||||
|
if texture == nil {
|
||||||
|
return nil, errors.New("纹理不存在")
|
||||||
|
}
|
||||||
|
if texture.Type != model.TextureTypeCape {
|
||||||
|
return nil, errors.New("仅披风纹理支持披风渲染")
|
||||||
|
}
|
||||||
|
|
||||||
|
textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("下载纹理失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := png.Decode(bytes.NewReader(textureData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解码PNG失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered := s.renderCapeView(img, size)
|
||||||
|
|
||||||
|
encoded, err := encodeImage(rendered, format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("编码披风渲染失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, RenderType("cape"), size, format, encoded, contentType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("保存披风渲染失败: %w", err)
|
||||||
|
}
|
||||||
|
if srcInfo != nil && result.LastModified.IsZero() {
|
||||||
|
result.LastModified = srcInfo.LastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil {
|
||||||
|
s.logger.Warn("缓存披风渲染失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPreview 渲染预览图(类似 Blessing Skin preview)
|
||||||
|
func (s *textureRenderService) RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) {
|
||||||
|
if size <= 0 || size > 2048 {
|
||||||
|
return nil, errors.New("预览渲染尺寸必须在1到2048之间")
|
||||||
|
}
|
||||||
|
|
||||||
|
texture, err := s.textureRepo.FindByID(ctx, textureID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取纹理失败: %w", err)
|
||||||
|
}
|
||||||
|
if texture == nil {
|
||||||
|
return nil, errors.New("纹理不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch texture.Type {
|
||||||
|
case model.TextureTypeCape:
|
||||||
|
return s.RenderCape(ctx, textureID, size, format)
|
||||||
|
default:
|
||||||
|
// 使用改进的等距视图作为默认预览
|
||||||
|
return s.RenderTexture(ctx, textureID, RenderTypeIsometric, size, format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderTextureFromData 从原始数据渲染纹理
|
||||||
|
func (s *textureRenderService) RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error) {
|
||||||
|
// 解码PNG图像
|
||||||
|
img, err := png.Decode(bytes.NewReader(textureData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("解码PNG失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType, err := normalizeFormat(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据渲染类型处理图像
|
||||||
|
var renderedImage image.Image
|
||||||
|
switch renderType {
|
||||||
|
case RenderTypeFront:
|
||||||
|
renderedImage = s.renderFrontView(img, isSlim, size)
|
||||||
|
case RenderTypeBack:
|
||||||
|
renderedImage = s.renderBackView(img, isSlim, size)
|
||||||
|
case RenderTypeFull:
|
||||||
|
renderedImage = s.renderFullView(img, isSlim, size)
|
||||||
|
case RenderTypeHead:
|
||||||
|
renderedImage = s.renderHeadView(img, size)
|
||||||
|
case RenderTypeIsometric:
|
||||||
|
renderedImage = s.renderIsometricView(img, isSlim, size)
|
||||||
|
default:
|
||||||
|
return nil, "", errors.New("不支持的渲染类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeImage(renderedImage, format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("编码纹理失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return encoded, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRenderURL 获取渲染图的URL
|
||||||
|
func (s *textureRenderService) GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string {
|
||||||
|
// 构建渲染图的存储路径
|
||||||
|
// 格式: renders/{textureID}/{renderType}/{size}.{ext}
|
||||||
|
ext := string(format)
|
||||||
|
if ext == "" {
|
||||||
|
ext = string(ImageFormatPNG)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("renders/%d/%s/%d.%s", textureID, renderType, size, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRenderCache 删除渲染缓存
|
||||||
|
func (s *textureRenderService) DeleteRenderCache(ctx context.Context, textureID int64) error {
|
||||||
|
// 删除所有渲染类型与格式的缓存
|
||||||
|
renderTypes := []RenderType{
|
||||||
|
RenderTypeFront, RenderTypeBack, RenderTypeFull, RenderTypeHead,
|
||||||
|
RenderTypeIsometric, RenderType("avatar-2d"), RenderType("avatar-3d"), RenderType("cape"),
|
||||||
|
}
|
||||||
|
formats := []ImageFormat{ImageFormatPNG, ImageFormatWEBP}
|
||||||
|
sizes := []int{64, 128, 256, 512}
|
||||||
|
|
||||||
|
for _, renderType := range renderTypes {
|
||||||
|
for _, size := range sizes {
|
||||||
|
for _, format := range formats {
|
||||||
|
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderType, format), size)
|
||||||
|
if err := s.cache.Delete(ctx, cacheKey); err != nil {
|
||||||
|
s.logger.Warn("删除渲染缓存失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadTexture 从对象存储下载纹理
|
||||||
|
func (s *textureRenderService) downloadTexture(ctx context.Context, textureURL string) ([]byte, *storage.ObjectInfo, error) {
|
||||||
|
// 先直接通过 HTTP GET 下载(对公有/匿名可读对象最兼容)
|
||||||
|
if resp, httpErr := http.Get(textureURL); httpErr == nil && resp != nil && resp.StatusCode == http.StatusOK {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
if readErr == nil {
|
||||||
|
var lm time.Time
|
||||||
|
if t, parseErr := http.ParseTime(resp.Header.Get("Last-Modified")); parseErr == nil {
|
||||||
|
lm = t
|
||||||
|
}
|
||||||
|
return body, &storage.ObjectInfo{
|
||||||
|
Size: resp.ContentLength,
|
||||||
|
LastModified: lm,
|
||||||
|
ContentType: resp.Header.Get("Content-Type"),
|
||||||
|
ETag: resp.Header.Get("ETag"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若 HTTP 失败,再尝试通过对象存储 SDK 访问
|
||||||
|
bucket, objectName, err := s.storage.ParseFileURL(textureURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("解析纹理URL失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, info, err := s.storage.GetObject(ctx, bucket, objectName)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("获取纹理对象失败",
|
||||||
|
zap.String("texture_url", textureURL),
|
||||||
|
zap.String("bucket", bucket),
|
||||||
|
zap.String("object", objectName),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, nil, fmt.Errorf("获取纹理对象失败: bucket=%s object=%s err=%v", bucket, objectName, err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
data, readErr := io.ReadAll(reader)
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, nil, readErr
|
||||||
|
}
|
||||||
|
return data, info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveRenderToStorage 保存渲染结果到对象存储
|
||||||
|
func (s *textureRenderService) saveRenderToStorage(ctx context.Context, textureID int64, textureHash string, renderType RenderType, size int, format ImageFormat, imageData []byte, contentType string) (*RenderResult, error) {
|
||||||
|
// 获取存储桶
|
||||||
|
bucketName, err := s.storage.GetBucket("renders")
|
||||||
|
if err != nil {
|
||||||
|
// 如果renders桶不存在,使用textures桶
|
||||||
|
bucketName, err = s.storage.GetBucket("textures")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取存储桶失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(textureHash) < 4 {
|
||||||
|
return nil, errors.New("纹理哈希长度不足,无法生成路径")
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := string(format)
|
||||||
|
objectName := fmt.Sprintf("renders/%s/%s/%s_%s_%d.%s",
|
||||||
|
textureHash[:2], textureHash[2:4], textureHash, renderType, size, ext)
|
||||||
|
|
||||||
|
// 上传到对象存储
|
||||||
|
reader := bytes.NewReader(imageData)
|
||||||
|
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(len(imageData)), contentType); err != nil {
|
||||||
|
return nil, fmt.Errorf("上传渲染结果失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag := sha256.Sum256(imageData)
|
||||||
|
result := &RenderResult{
|
||||||
|
URL: s.storage.BuildFileURL(bucketName, objectName),
|
||||||
|
ContentType: contentType,
|
||||||
|
ETag: hex.EncodeToString(etag[:]),
|
||||||
|
LastModified: time.Now().UTC(),
|
||||||
|
Size: int64(len(imageData)),
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderFrontView 渲染正面视图(分块+第二层,含 Alex/Steve)
|
||||||
|
func (s *textureRenderService) renderFrontView(img image.Image, isSlim bool, size int) image.Image {
|
||||||
|
base := composeFrontModel(img, isSlim)
|
||||||
|
return scaleNearest(base, size, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBackView 渲染背面视图(分块+第二层)
|
||||||
|
func (s *textureRenderService) renderBackView(img image.Image, isSlim bool, size int) image.Image {
|
||||||
|
base := composeBackModel(img, isSlim)
|
||||||
|
return scaleNearest(base, size, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderFullView 渲染全身视图(正面+背面)
|
||||||
|
func (s *textureRenderService) renderFullView(img image.Image, isSlim bool, size int) image.Image {
|
||||||
|
front := composeFrontModel(img, isSlim)
|
||||||
|
back := composeBackModel(img, isSlim)
|
||||||
|
|
||||||
|
full := image.NewRGBA(image.Rect(0, 0, front.Bounds().Dx()+back.Bounds().Dx(), front.Bounds().Dy()))
|
||||||
|
draw.Draw(full, image.Rect(0, 0, front.Bounds().Dx(), front.Bounds().Dy()), front, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(full, image.Rect(front.Bounds().Dx(), 0, full.Bounds().Dx(), full.Bounds().Dy()), back, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
return scaleNearest(full, size*2, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderHeadView 渲染头像视图(包含第二层帽子)
|
||||||
|
func (s *textureRenderService) renderHeadView(img image.Image, size int) image.Image {
|
||||||
|
headBase := safeCrop(img, image.Rect(8, 8, 16, 16))
|
||||||
|
headOverlay := safeCrop(img, image.Rect(40, 8, 48, 16))
|
||||||
|
|
||||||
|
if headBase == nil {
|
||||||
|
// 返回空白头像
|
||||||
|
return scaleNearest(image.NewRGBA(image.Rect(0, 0, 8, 8)), size, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, headBase.Bounds().Dx(), headBase.Bounds().Dy()))
|
||||||
|
draw.Draw(canvas, canvas.Bounds(), headBase, headBase.Bounds().Min, draw.Src)
|
||||||
|
if headOverlay != nil {
|
||||||
|
draw.Draw(canvas, canvas.Bounds(), headOverlay, headOverlay.Bounds().Min, draw.Over)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scaleNearest(canvas, size, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderIsometricView 渲染等距视图(使用 Blessing Skin 风格的真 3D 渲染)
|
||||||
|
func (s *textureRenderService) renderIsometricView(img image.Image, isSlim bool, size int) image.Image {
|
||||||
|
// 将图像编码为 PNG 数据
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
// 编码失败,回退到简单渲染
|
||||||
|
return s.renderIsometricViewFallback(img, isSlim, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用新的 3D 渲染器渲染完整皮肤
|
||||||
|
ratio := float64(size) / 32.0 // 基准比例,32 像素高度的皮肤
|
||||||
|
rendered, err := s.minecraft.RenderSkin(buf.Bytes(), ratio, isSlim)
|
||||||
|
if err != nil {
|
||||||
|
// 渲染失败,回退到简单渲染
|
||||||
|
return s.renderIsometricViewFallback(img, isSlim, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderIsometricViewFallback 等距视图回退方案(简单 2D)
|
||||||
|
func (s *textureRenderService) renderIsometricViewFallback(img image.Image, isSlim bool, size int) image.Image {
|
||||||
|
result := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||||
|
|
||||||
|
bgColor := color.RGBA{240, 240, 240, 255}
|
||||||
|
draw.Draw(result, result.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
front := scaleNearest(composeFrontModel(img, isSlim), size/2, size/2)
|
||||||
|
|
||||||
|
for y := 0; y < front.Bounds().Dy(); y++ {
|
||||||
|
for x := 0; x < front.Bounds().Dx(); x++ {
|
||||||
|
destX := x + size/4
|
||||||
|
destY := y + size/4
|
||||||
|
depth := float64(x) / float64(front.Bounds().Dx())
|
||||||
|
brightness := 1.0 - depth*0.25
|
||||||
|
|
||||||
|
c := front.At(x, y)
|
||||||
|
r, g, b, a := c.RGBA()
|
||||||
|
newR := uint32(float64(r) * brightness)
|
||||||
|
newG := uint32(float64(g) * brightness)
|
||||||
|
newB := uint32(float64(b) * brightness)
|
||||||
|
|
||||||
|
if a > 0 {
|
||||||
|
result.Set(destX, destY, color.RGBA64{
|
||||||
|
R: uint16(newR),
|
||||||
|
G: uint16(newG),
|
||||||
|
B: uint16(newB),
|
||||||
|
A: uint16(a),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
borderColor := color.RGBA{200, 200, 200, 255}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
drawLine(result, size/4, size/4, size*3/4, size/4, borderColor)
|
||||||
|
drawLine(result, size/4, size*3/4, size*3/4, size*3/4, borderColor)
|
||||||
|
drawLine(result, size/4, size/4, size/4, size*3/4, borderColor)
|
||||||
|
drawLine(result, size*3/4, size/4, size*3/4, size*3/4, borderColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawLine 绘制直线
|
||||||
|
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) {
|
||||||
|
dx := abs(x2 - x1)
|
||||||
|
dy := abs(y2 - y1)
|
||||||
|
sx := -1
|
||||||
|
if x1 < x2 {
|
||||||
|
sx = 1
|
||||||
|
}
|
||||||
|
sy := -1
|
||||||
|
if y1 < y2 {
|
||||||
|
sy = 1
|
||||||
|
}
|
||||||
|
err := dx - dy
|
||||||
|
|
||||||
|
for {
|
||||||
|
img.Set(x1, y1, c)
|
||||||
|
if x1 == x2 && y1 == y2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
e2 := 2 * err
|
||||||
|
if e2 > -dy {
|
||||||
|
err -= dy
|
||||||
|
x1 += sx
|
||||||
|
}
|
||||||
|
if e2 < dx {
|
||||||
|
err += dx
|
||||||
|
y1 += sy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// abs 绝对值
|
||||||
|
func abs(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCapeView 渲染披风(使用新渲染器)
|
||||||
|
func (s *textureRenderService) renderCapeView(img image.Image, size int) image.Image {
|
||||||
|
// 将图像编码为 PNG 数据
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
// 编码失败,回退到简单缩放
|
||||||
|
srcBounds := img.Bounds()
|
||||||
|
if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
return scaleNearest(img, size*2, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用新的披风渲染器
|
||||||
|
rendered, err := s.minecraft.RenderCape(buf.Bytes(), size)
|
||||||
|
if err != nil {
|
||||||
|
// 渲染失败,回退到简单缩放
|
||||||
|
srcBounds := img.Bounds()
|
||||||
|
if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
return scaleNearest(img, size*2, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeFrontModel 组合正面分块(含第二层)
|
||||||
|
func composeFrontModel(img image.Image, isSlim bool) *image.RGBA {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 16, 32))
|
||||||
|
armW := 4
|
||||||
|
if isSlim {
|
||||||
|
armW = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
drawLayeredPart(canvas, image.Rect(4, 0, 12, 8),
|
||||||
|
safeCrop(img, image.Rect(8, 8, 16, 16)),
|
||||||
|
safeCrop(img, image.Rect(40, 8, 48, 16)))
|
||||||
|
|
||||||
|
drawLayeredPart(canvas, image.Rect(4, 8, 12, 20),
|
||||||
|
safeCrop(img, image.Rect(20, 20, 28, 32)),
|
||||||
|
safeCrop(img, image.Rect(20, 36, 28, 48)))
|
||||||
|
|
||||||
|
drawLayeredPart(canvas, image.Rect(0, 8, armW, 20),
|
||||||
|
safeCrop(img, image.Rect(44, 20, 48, 32)),
|
||||||
|
safeCrop(img, image.Rect(44, 36, 48, 48)))
|
||||||
|
drawLayeredPart(canvas, image.Rect(16-armW, 8, 16, 20),
|
||||||
|
safeCrop(img, image.Rect(36, 52, 40, 64)),
|
||||||
|
safeCrop(img, image.Rect(52, 52, 56, 64)))
|
||||||
|
|
||||||
|
drawLayeredPart(canvas, image.Rect(4, 20, 8, 32),
|
||||||
|
safeCrop(img, image.Rect(4, 20, 8, 32)),
|
||||||
|
safeCrop(img, image.Rect(4, 36, 8, 48)))
|
||||||
|
drawLayeredPart(canvas, image.Rect(8, 20, 12, 32),
|
||||||
|
safeCrop(img, image.Rect(20, 52, 24, 64)),
|
||||||
|
safeCrop(img, image.Rect(4, 52, 8, 64)))
|
||||||
|
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeBackModel 组合背面分块(含第二层)
|
||||||
|
func composeBackModel(img image.Image, isSlim bool) *image.RGBA {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 16, 32))
|
||||||
|
armW := 4
|
||||||
|
if isSlim {
|
||||||
|
armW = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
drawLayeredPart(canvas, image.Rect(4, 0, 12, 8),
|
||||||
|
safeCrop(img, image.Rect(24, 8, 32, 16)),
|
||||||
|
safeCrop(img, image.Rect(56, 8, 64, 16)))
|
||||||
|
|
||||||
|
drawLayeredPart(canvas, image.Rect(4, 8, 12, 20),
|
||||||
|
safeCrop(img, image.Rect(32, 20, 40, 32)),
|
||||||
|
safeCrop(img, image.Rect(32, 36, 40, 48)))
|
||||||
|
|
||||||
|
drawLayeredPart(canvas, image.Rect(0, 8, armW, 20),
|
||||||
|
safeCrop(img, image.Rect(52, 20, 56, 32)),
|
||||||
|
safeCrop(img, image.Rect(52, 36, 56, 48)))
|
||||||
|
drawLayeredPart(canvas, image.Rect(16-armW, 8, 16, 20),
|
||||||
|
safeCrop(img, image.Rect(44, 52, 48, 64)),
|
||||||
|
safeCrop(img, image.Rect(60, 52, 64, 64)))
|
||||||
|
|
||||||
|
drawLayeredPart(canvas, image.Rect(4, 20, 8, 32),
|
||||||
|
safeCrop(img, image.Rect(12, 20, 16, 32)),
|
||||||
|
safeCrop(img, image.Rect(12, 36, 16, 48)))
|
||||||
|
drawLayeredPart(canvas, image.Rect(8, 20, 12, 32),
|
||||||
|
safeCrop(img, image.Rect(28, 52, 32, 64)),
|
||||||
|
safeCrop(img, image.Rect(12, 52, 16, 64)))
|
||||||
|
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawLayeredPart 绘制单个分块(基础层+第二层,正确的 Alpha 混合)
|
||||||
|
func drawLayeredPart(dst draw.Image, dstRect image.Rectangle, base image.Image, overlay image.Image) {
|
||||||
|
if base == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dstW := dstRect.Dx()
|
||||||
|
dstH := dstRect.Dy()
|
||||||
|
|
||||||
|
// 绘制基础层
|
||||||
|
for y := 0; y < dstH; y++ {
|
||||||
|
for x := 0; x < dstW; x++ {
|
||||||
|
srcX := base.Bounds().Min.X + x*base.Bounds().Dx()/dstW
|
||||||
|
srcY := base.Bounds().Min.Y + y*base.Bounds().Dy()/dstH
|
||||||
|
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, base.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制第二层(使用 Alpha 混合)
|
||||||
|
if overlay != nil {
|
||||||
|
for y := 0; y < dstH; y++ {
|
||||||
|
for x := 0; x < dstW; x++ {
|
||||||
|
srcX := overlay.Bounds().Min.X + x*overlay.Bounds().Dx()/dstW
|
||||||
|
srcY := overlay.Bounds().Min.Y + y*overlay.Bounds().Dy()/dstH
|
||||||
|
overlayColor := overlay.At(srcX, srcY)
|
||||||
|
|
||||||
|
// 获取 overlay 的 alpha 值
|
||||||
|
_, _, _, a := overlayColor.RGBA()
|
||||||
|
if a == 0 {
|
||||||
|
// 完全透明,跳过
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if a == 0xFFFF {
|
||||||
|
// 完全不透明,直接覆盖
|
||||||
|
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, overlayColor)
|
||||||
|
} else {
|
||||||
|
// 半透明,进行 Alpha 混合
|
||||||
|
baseColor := dst.At(dstRect.Min.X+x, dstRect.Min.Y+y)
|
||||||
|
blended := alphaBlendColors(baseColor, overlayColor)
|
||||||
|
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, blended)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// alphaBlendColors 执行 Alpha 混合
|
||||||
|
func alphaBlendColors(dst, src color.Color) color.Color {
|
||||||
|
sr, sg, sb, sa := src.RGBA()
|
||||||
|
dr, dg, db, da := dst.RGBA()
|
||||||
|
|
||||||
|
if sa == 0 {
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
if sa == 0xFFFF {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alpha 混合公式
|
||||||
|
srcA := float64(sa) / 0xFFFF
|
||||||
|
dstA := float64(da) / 0xFFFF
|
||||||
|
outA := srcA + dstA*(1-srcA)
|
||||||
|
|
||||||
|
if outA == 0 {
|
||||||
|
return color.RGBA{}
|
||||||
|
}
|
||||||
|
|
||||||
|
outR := (float64(sr)*srcA + float64(dr)*dstA*(1-srcA)) / outA
|
||||||
|
outG := (float64(sg)*srcA + float64(dg)*dstA*(1-srcA)) / outA
|
||||||
|
outB := (float64(sb)*srcA + float64(db)*dstA*(1-srcA)) / outA
|
||||||
|
|
||||||
|
return color.RGBA64{
|
||||||
|
R: uint16(outR),
|
||||||
|
G: uint16(outG),
|
||||||
|
B: uint16(outB),
|
||||||
|
A: uint16(outA * 0xFFFF),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeCrop 安全裁剪(超界返回nil)
|
||||||
|
func safeCrop(img image.Image, rect image.Rectangle) image.Image {
|
||||||
|
b := img.Bounds()
|
||||||
|
if rect.Min.X < 0 || rect.Min.Y < 0 || rect.Max.X > b.Max.X || rect.Max.Y > b.Max.Y {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
subImg := image.NewRGBA(rect)
|
||||||
|
draw.Draw(subImg, rect, img, rect.Min, draw.Src)
|
||||||
|
return subImg
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleNearest 最近邻缩放
|
||||||
|
func scaleNearest(src image.Image, targetW, targetH int) *image.RGBA {
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
|
||||||
|
srcBounds := src.Bounds()
|
||||||
|
for y := 0; y < targetH; y++ {
|
||||||
|
for x := 0; x < targetW; x++ {
|
||||||
|
srcX := srcBounds.Min.X + x*srcBounds.Dx()/targetW
|
||||||
|
srcY := srcBounds.Min.Y + y*srcBounds.Dy()/targetH
|
||||||
|
dst.Set(x, y, src.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeFormat 校验输出格式
|
||||||
|
func normalizeFormat(format ImageFormat) (string, error) {
|
||||||
|
if format == "" {
|
||||||
|
format = ImageFormatPNG
|
||||||
|
}
|
||||||
|
switch format {
|
||||||
|
case ImageFormatPNG:
|
||||||
|
return "image/png", nil
|
||||||
|
case ImageFormatWEBP:
|
||||||
|
return "image/webp", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("不支持的输出格式: %s", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeImage 将图像编码为指定格式
|
||||||
|
func encodeImage(img image.Image, format ImageFormat) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
switch format {
|
||||||
|
case ImageFormatWEBP:
|
||||||
|
if err := webp.Encode(&buf, img, &webp.Options{Lossless: true}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -47,62 +48,11 @@ func NewTextureService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *textureService) Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) {
|
|
||||||
// 验证用户存在
|
|
||||||
user, err := s.userRepo.FindByID(ctx, uploaderID)
|
|
||||||
if err != nil || user == nil {
|
|
||||||
return nil, ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有任何用户上传过相同Hash的皮肤(复用URL,不重复保存文件)
|
|
||||||
existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果已存在相同Hash的皮肤,复用已存在的URL
|
|
||||||
finalURL := url
|
|
||||||
if existingTexture != nil {
|
|
||||||
finalURL = existingTexture.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换材质类型
|
|
||||||
textureTypeEnum, err := parseTextureTypeInternal(textureType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建材质记录(即使Hash相同,也创建新的数据库记录)
|
|
||||||
texture := &model.Texture{
|
|
||||||
UploaderID: uploaderID,
|
|
||||||
Name: name,
|
|
||||||
Description: description,
|
|
||||||
Type: textureTypeEnum,
|
|
||||||
URL: finalURL, // 复用已存在的URL或使用新URL
|
|
||||||
Hash: hash,
|
|
||||||
Size: size,
|
|
||||||
IsPublic: isPublic,
|
|
||||||
IsSlim: isSlim,
|
|
||||||
Status: 1,
|
|
||||||
DownloadCount: 0,
|
|
||||||
FavoriteCount: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.textureRepo.Create(ctx, texture); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除用户的 texture 列表缓存(所有分页)
|
|
||||||
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
|
|
||||||
|
|
||||||
return texture, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) {
|
func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) {
|
||||||
// 尝试从缓存获取
|
// 尝试从缓存获取
|
||||||
cacheKey := s.cacheKeys.Texture(id)
|
cacheKey := s.cacheKeys.Texture(id)
|
||||||
var texture model.Texture
|
var texture model.Texture
|
||||||
if ok, _ := s.cache.TryGet(ctx, cacheKey, &texture); ok {
|
if err := s.cache.Get(ctx, cacheKey, &texture); err == nil {
|
||||||
if texture.Status == -1 {
|
if texture.Status == -1 {
|
||||||
return nil, errors.New("材质已删除")
|
return nil, errors.New("材质已删除")
|
||||||
}
|
}
|
||||||
@@ -121,9 +71,11 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
|
|||||||
return nil, errors.New("材质已删除")
|
return nil, errors.New("材质已删除")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存入缓存(异步)
|
// 存入缓存(异步,5分钟过期)
|
||||||
if texture2 != nil {
|
if texture2 != nil {
|
||||||
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
|
go func() {
|
||||||
|
_ = s.cache.Set(context.Background(), cacheKey, texture2, 5*time.Minute)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return texture2, nil
|
return texture2, nil
|
||||||
@@ -133,7 +85,7 @@ func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Tex
|
|||||||
// 尝试从缓存获取
|
// 尝试从缓存获取
|
||||||
cacheKey := s.cacheKeys.TextureByHash(hash)
|
cacheKey := s.cacheKeys.TextureByHash(hash)
|
||||||
var texture model.Texture
|
var texture model.Texture
|
||||||
if ok, _ := s.cache.TryGet(ctx, cacheKey, &texture); ok {
|
if err := s.cache.Get(ctx, cacheKey, &texture); err == nil {
|
||||||
if texture.Status == -1 {
|
if texture.Status == -1 {
|
||||||
return nil, errors.New("材质已删除")
|
return nil, errors.New("材质已删除")
|
||||||
}
|
}
|
||||||
@@ -152,8 +104,10 @@ func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Tex
|
|||||||
return nil, errors.New("材质已删除")
|
return nil, errors.New("材质已删除")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存入缓存(异步)
|
// 存入缓存(异步,5分钟过期)
|
||||||
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
|
go func() {
|
||||||
|
_ = s.cache.Set(context.Background(), cacheKey, texture2, 5*time.Minute)
|
||||||
|
}()
|
||||||
|
|
||||||
return texture2, nil
|
return texture2, nil
|
||||||
}
|
}
|
||||||
@@ -167,7 +121,7 @@ func (s *textureService) GetByUserID(ctx context.Context, uploaderID int64, page
|
|||||||
Textures []*model.Texture
|
Textures []*model.Texture
|
||||||
Total int64
|
Total int64
|
||||||
}
|
}
|
||||||
if ok, _ := s.cache.TryGet(ctx, cacheKey, &cachedResult); ok {
|
if err := s.cache.Get(ctx, cacheKey, &cachedResult); err == nil {
|
||||||
return cachedResult.Textures, cachedResult.Total, nil
|
return cachedResult.Textures, cachedResult.Total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,12 +131,14 @@ func (s *textureService) GetByUserID(ctx context.Context, uploaderID int64, page
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存入缓存(异步)
|
// 存入缓存(异步,2分钟过期)
|
||||||
|
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.SetAsync(context.Background(), cacheKey, result, s.cache.Policy.TextureListTTL)
|
_ = s.cache.Set(context.Background(), cacheKey, result, 2*time.Minute)
|
||||||
|
}()
|
||||||
|
|
||||||
return textures, total, nil
|
return textures, total, nil
|
||||||
}
|
}
|
||||||
@@ -225,7 +181,7 @@ func (s *textureService) Update(ctx context.Context, textureID, uploaderID int64
|
|||||||
|
|
||||||
// 清除 texture 缓存和用户列表缓存
|
// 清除 texture 缓存和用户列表缓存
|
||||||
s.cacheInv.OnUpdate(ctx, s.cacheKeys.Texture(textureID))
|
s.cacheInv.OnUpdate(ctx, s.cacheKeys.Texture(textureID))
|
||||||
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.TextureListPattern(uploaderID))
|
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
|
||||||
|
|
||||||
return s.textureRepo.FindByID(ctx, textureID)
|
return s.textureRepo.FindByID(ctx, textureID)
|
||||||
}
|
}
|
||||||
@@ -250,7 +206,7 @@ func (s *textureService) Delete(ctx context.Context, textureID, uploaderID int64
|
|||||||
|
|
||||||
// 清除 texture 缓存和用户列表缓存
|
// 清除 texture 缓存和用户列表缓存
|
||||||
s.cacheInv.OnDelete(ctx, s.cacheKeys.Texture(textureID))
|
s.cacheInv.OnDelete(ctx, s.cacheKeys.Texture(textureID))
|
||||||
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.TextureListPattern(uploaderID))
|
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,128 +478,6 @@ func boolPtr(b bool) *bool {
|
|||||||
// 使用 Mock 的集成测试
|
// 使用 Mock 的集成测试
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// TestTextureServiceImpl_Create 测试创建Texture
|
|
||||||
func TestTextureServiceImpl_Create(t *testing.T) {
|
|
||||||
textureRepo := NewMockTextureRepository()
|
|
||||||
userRepo := NewMockUserRepository()
|
|
||||||
logger := zap.NewNop()
|
|
||||||
|
|
||||||
// 预置用户
|
|
||||||
testUser := &model.User{
|
|
||||||
ID: 1,
|
|
||||||
Username: "testuser",
|
|
||||||
Email: "test@example.com",
|
|
||||||
Status: 1,
|
|
||||||
}
|
|
||||||
_ = userRepo.Create(context.Background(), testUser)
|
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
|
||||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
uploaderID int64
|
|
||||||
textureName string
|
|
||||||
textureType string
|
|
||||||
hash string
|
|
||||||
wantErr bool
|
|
||||||
errContains string
|
|
||||||
setupMocks func()
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "正常创建SKIN材质",
|
|
||||||
uploaderID: 1,
|
|
||||||
textureName: "TestSkin",
|
|
||||||
textureType: "SKIN",
|
|
||||||
hash: "unique-hash-1",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "正常创建CAPE材质",
|
|
||||||
uploaderID: 1,
|
|
||||||
textureName: "TestCape",
|
|
||||||
textureType: "CAPE",
|
|
||||||
hash: "unique-hash-2",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "用户不存在",
|
|
||||||
uploaderID: 999,
|
|
||||||
textureName: "TestTexture",
|
|
||||||
textureType: "SKIN",
|
|
||||||
hash: "unique-hash-3",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "材质Hash已存在",
|
|
||||||
uploaderID: 1,
|
|
||||||
textureName: "DuplicateTexture",
|
|
||||||
textureType: "SKIN",
|
|
||||||
hash: "existing-hash",
|
|
||||||
wantErr: false,
|
|
||||||
setupMocks: func() {
|
|
||||||
_ = textureRepo.Create(context.Background(), &model.Texture{
|
|
||||||
ID: 100,
|
|
||||||
UploaderID: 1,
|
|
||||||
Name: "ExistingTexture",
|
|
||||||
Hash: "existing-hash",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "无效的材质类型",
|
|
||||||
uploaderID: 1,
|
|
||||||
textureName: "InvalidTypeTexture",
|
|
||||||
textureType: "INVALID",
|
|
||||||
hash: "unique-hash-4",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if tt.setupMocks != nil {
|
|
||||||
tt.setupMocks()
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
texture, err := textureService.Create(
|
|
||||||
ctx,
|
|
||||||
tt.uploaderID,
|
|
||||||
tt.textureName,
|
|
||||||
"Test description",
|
|
||||||
tt.textureType,
|
|
||||||
"http://example.com/texture.png",
|
|
||||||
tt.hash,
|
|
||||||
512,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Error("期望返回错误,但实际没有错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
|
|
||||||
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("不期望返回错误: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if texture == nil {
|
|
||||||
t.Error("返回的Texture不应为nil")
|
|
||||||
}
|
|
||||||
if texture.Name != tt.textureName {
|
|
||||||
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTextureServiceImpl_GetByID 测试获取Texture
|
// TestTextureServiceImpl_GetByID 测试获取Texture
|
||||||
func TestTextureServiceImpl_GetByID(t *testing.T) {
|
func TestTextureServiceImpl_GetByID(t *testing.T) {
|
||||||
textureRepo := NewMockTextureRepository()
|
textureRepo := NewMockTextureRepository()
|
||||||
|
|||||||
305
internal/service/token_service.go
Normal file
305
internal/service/token_service.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/repository"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenService TokenService的实现
|
||||||
|
type tokenService struct {
|
||||||
|
tokenRepo repository.TokenRepository
|
||||||
|
profileRepo repository.ProfileRepository
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenService 创建TokenService实例
|
||||||
|
func NewTokenService(
|
||||||
|
tokenRepo repository.TokenRepository,
|
||||||
|
profileRepo repository.ProfileRepository,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) TokenService {
|
||||||
|
return &tokenService{
|
||||||
|
tokenRepo: tokenRepo,
|
||||||
|
profileRepo: profileRepo,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenExtendedTimeout = 10 * time.Second
|
||||||
|
tokensMaxCount = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *tokenService) Create(ctx context.Context, userID int64, UUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
|
||||||
|
var (
|
||||||
|
selectedProfileID *model.Profile
|
||||||
|
availableProfiles []*model.Profile
|
||||||
|
)
|
||||||
|
|
||||||
|
// 设置超时上下文
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 验证用户存在
|
||||||
|
if UUID != "" {
|
||||||
|
_, err := s.profileRepo.FindByUUID(ctx, UUID)
|
||||||
|
if err != nil {
|
||||||
|
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户信息失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成令牌
|
||||||
|
if clientToken == "" {
|
||||||
|
clientToken = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := uuid.New().String()
|
||||||
|
token := model.Token{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
ClientToken: clientToken,
|
||||||
|
UserID: userID,
|
||||||
|
Usable: true,
|
||||||
|
IssueDate: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户配置文件
|
||||||
|
profiles, err := s.profileRepo.FindByUserID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果用户只有一个配置文件,自动选择
|
||||||
|
if len(profiles) == 1 {
|
||||||
|
selectedProfileID = profiles[0]
|
||||||
|
token.ProfileId = selectedProfileID.UUID
|
||||||
|
}
|
||||||
|
availableProfiles = profiles
|
||||||
|
|
||||||
|
// 插入令牌
|
||||||
|
err = s.tokenRepo.Create(ctx, &token)
|
||||||
|
if err != nil {
|
||||||
|
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("创建Token失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理多余的令牌(使用独立的后台上下文)
|
||||||
|
go s.checkAndCleanupExcessTokens(context.Background(), userID)
|
||||||
|
|
||||||
|
return selectedProfileID, availableProfiles, accessToken, clientToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenService) Validate(ctx context.Context, accessToken, clientToken string) bool {
|
||||||
|
// 设置超时上下文
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if accessToken == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := s.tokenRepo.FindByAccessToken(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Usable {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientToken == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.ClientToken == clientToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenService) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) {
|
||||||
|
// 设置超时上下文
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if accessToken == "" {
|
||||||
|
return "", "", errors.New("accessToken不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找旧令牌
|
||||||
|
oldToken, err := s.tokenRepo.FindByAccessToken(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", "", errors.New("accessToken无效")
|
||||||
|
}
|
||||||
|
s.logger.Error("查询Token失败", zap.Error(err), zap.String("accessToken", accessToken))
|
||||||
|
return "", "", fmt.Errorf("查询令牌失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证profile
|
||||||
|
if selectedProfileID != "" {
|
||||||
|
valid, validErr := s.validateProfileByUserID(ctx, oldToken.UserID, selectedProfileID)
|
||||||
|
if validErr != nil {
|
||||||
|
s.logger.Error("验证Profile失败",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Int64("userId", oldToken.UserID),
|
||||||
|
zap.String("profileId", selectedProfileID),
|
||||||
|
)
|
||||||
|
return "", "", fmt.Errorf("验证角色失败: %w", err)
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return "", "", errors.New("角色与用户不匹配")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 clientToken 是否有效
|
||||||
|
if clientToken != "" && clientToken != oldToken.ClientToken {
|
||||||
|
return "", "", errors.New("clientToken无效")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 selectedProfileID 的逻辑
|
||||||
|
if selectedProfileID != "" {
|
||||||
|
if oldToken.ProfileId != "" && oldToken.ProfileId != selectedProfileID {
|
||||||
|
return "", "", errors.New("原令牌已绑定角色,无法选择新角色")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedProfileID = oldToken.ProfileId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新令牌
|
||||||
|
newAccessToken := uuid.New().String()
|
||||||
|
newToken := model.Token{
|
||||||
|
AccessToken: newAccessToken,
|
||||||
|
ClientToken: oldToken.ClientToken,
|
||||||
|
UserID: oldToken.UserID,
|
||||||
|
Usable: true,
|
||||||
|
ProfileId: selectedProfileID,
|
||||||
|
IssueDate: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先插入新令牌,再删除旧令牌
|
||||||
|
err = s.tokenRepo.Create(ctx, &newToken)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("创建新Token失败", zap.Error(err), zap.String("accessToken", accessToken))
|
||||||
|
return "", "", fmt.Errorf("创建新Token失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.tokenRepo.DeleteByAccessToken(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("删除旧Token失败,但新Token已创建",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("oldToken", oldToken.AccessToken),
|
||||||
|
zap.String("newToken", newAccessToken),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("成功刷新Token", zap.Int64("userId", oldToken.UserID), zap.String("accessToken", newAccessToken))
|
||||||
|
return newAccessToken, oldToken.ClientToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenService) Invalidate(ctx context.Context, accessToken string) {
|
||||||
|
// 设置超时上下文
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if accessToken == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.tokenRepo.DeleteByAccessToken(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("删除Token失败", zap.Error(err), zap.String("accessToken", accessToken))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Info("成功删除Token", zap.String("token", accessToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenService) InvalidateUserTokens(ctx context.Context, userID int64) {
|
||||||
|
// 设置超时上下文
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.tokenRepo.DeleteByUserID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("删除用户Token失败", zap.Error(err), zap.Int64("userId", userID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("成功删除用户Token", zap.Int64("userId", userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenService) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
|
||||||
|
// 设置超时上下文
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return s.tokenRepo.GetUUIDByAccessToken(ctx, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenService) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
|
||||||
|
// 设置超时上下文
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return s.tokenRepo.GetUserIDByAccessToken(ctx, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 私有辅助方法
|
||||||
|
|
||||||
|
func (s *tokenService) checkAndCleanupExcessTokens(ctx context.Context, userID int64) {
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为清理操作设置更长的超时时间
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, tokenExtendedTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tokens, err := s.tokenRepo.GetByUserID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("获取用户Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tokens) <= tokensMaxCount {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokensToDelete := make([]string, 0, len(tokens)-tokensMaxCount)
|
||||||
|
for i := tokensMaxCount; i < len(tokens); i++ {
|
||||||
|
tokensToDelete = append(tokensToDelete, tokens[i].AccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedCount, err := s.tokenRepo.BatchDelete(ctx, tokensToDelete)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("清理用户多余Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deletedCount > 0 {
|
||||||
|
s.logger.Info("成功清理用户多余Token", zap.Int64("userId", userID), zap.Int64("count", deletedCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenService) validateProfileByUserID(ctx context.Context, userID int64, UUID string) (bool, error) {
|
||||||
|
if userID == 0 || UUID == "" {
|
||||||
|
return false, errors.New("用户ID或配置文件ID不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := s.profileRepo.FindByUUID(ctx, UUID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, errors.New("配置文件不存在")
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("验证配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
return profile.UserID == userID, nil
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -14,9 +15,9 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tokenServiceRedis TokenService的Redis实现
|
// tokenServiceJWT TokenService的JWT实现(使用JWT + Version机制)
|
||||||
type tokenServiceRedis struct {
|
type tokenServiceJWT struct {
|
||||||
tokenStore *auth.TokenStoreRedis
|
tokenRepo repository.TokenRepository
|
||||||
clientRepo repository.ClientRepository
|
clientRepo repository.ClientRepository
|
||||||
profileRepo repository.ProfileRepository
|
profileRepo repository.ProfileRepository
|
||||||
yggdrasilJWT *auth.YggdrasilJWTService
|
yggdrasilJWT *auth.YggdrasilJWTService
|
||||||
@@ -25,16 +26,16 @@ type tokenServiceRedis struct {
|
|||||||
tokenStaleSec int64 // Token过期但可用时间(秒),0表示永不过期
|
tokenStaleSec int64 // Token过期但可用时间(秒),0表示永不过期
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTokenServiceRedis 创建使用Redis的TokenService实例
|
// NewTokenServiceJWT 创建使用JWT的TokenService实例
|
||||||
func NewTokenServiceRedis(
|
func NewTokenServiceJWT(
|
||||||
tokenStore *auth.TokenStoreRedis,
|
tokenRepo repository.TokenRepository,
|
||||||
clientRepo repository.ClientRepository,
|
clientRepo repository.ClientRepository,
|
||||||
profileRepo repository.ProfileRepository,
|
profileRepo repository.ProfileRepository,
|
||||||
yggdrasilJWT *auth.YggdrasilJWTService,
|
yggdrasilJWT *auth.YggdrasilJWTService,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) TokenService {
|
) TokenService {
|
||||||
return &tokenServiceRedis{
|
return &tokenServiceJWT{
|
||||||
tokenStore: tokenStore,
|
tokenRepo: tokenRepo,
|
||||||
clientRepo: clientRepo,
|
clientRepo: clientRepo,
|
||||||
profileRepo: profileRepo,
|
profileRepo: profileRepo,
|
||||||
yggdrasilJWT: yggdrasilJWT,
|
yggdrasilJWT: yggdrasilJWT,
|
||||||
@@ -44,8 +45,10 @@ func NewTokenServiceRedis(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建Token(使用JWT + Redis存储)
|
// 常量已在 token_service.go 中定义,这里不重复定义
|
||||||
func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
|
|
||||||
|
// Create 创建Token(使用JWT + Version机制)
|
||||||
|
func (s *tokenServiceJWT) Create(ctx context.Context, userID int64, UUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
|
||||||
var (
|
var (
|
||||||
selectedProfileID *model.Profile
|
selectedProfileID *model.Profile
|
||||||
availableProfiles []*model.Profile
|
availableProfiles []*model.Profile
|
||||||
@@ -131,7 +134,7 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
|
|||||||
if s.tokenExpireSec > 0 {
|
if s.tokenExpireSec > 0 {
|
||||||
expiresAt = now.Add(time.Duration(s.tokenExpireSec) * time.Second)
|
expiresAt = now.Add(time.Duration(s.tokenExpireSec) * time.Second)
|
||||||
} else {
|
} else {
|
||||||
// 使用遥远的未来时间
|
// 使用遥远的未来时间(类似drasl的DISTANT_FUTURE)
|
||||||
expiresAt = time.Date(2038, 1, 1, 0, 0, 0, 0, time.UTC)
|
expiresAt = time.Date(2038, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,31 +157,36 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
|
|||||||
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成AccessToken失败: %w", err)
|
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成AccessToken失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存储Token到Redis
|
// 保存Token记录(用于查询和审计)
|
||||||
ttl := expiresAt.Sub(now)
|
token := model.Token{
|
||||||
metadata := &auth.TokenMetadata{
|
AccessToken: accessToken,
|
||||||
|
ClientToken: clientToken,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ProfileID: profileID,
|
ProfileId: profileID,
|
||||||
ClientUUID: client.UUID,
|
|
||||||
ClientToken: client.ClientToken,
|
|
||||||
Version: client.Version,
|
Version: client.Version,
|
||||||
CreatedAt: now.Unix(),
|
Usable: true,
|
||||||
|
IssueDate: now,
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
StaleAt: &staleAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil {
|
err = s.tokenRepo.Create(ctx, &token)
|
||||||
s.logger.Warn("存储Token到Redis失败", zap.Error(err))
|
if err != nil {
|
||||||
|
s.logger.Warn("保存Token记录失败,但JWT已生成", zap.Error(err))
|
||||||
// 不返回错误,因为JWT本身已经生成成功
|
// 不返回错误,因为JWT本身已经生成成功
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理多余的令牌(使用独立的后台上下文)
|
||||||
|
go s.checkAndCleanupExcessTokens(context.Background(), userID)
|
||||||
|
|
||||||
return selectedProfileID, availableProfiles, accessToken, clientToken, nil
|
return selectedProfileID, availableProfiles, accessToken, clientToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate 验证Token(使用JWT验证 + Redis存储验证)
|
// Validate 验证Token(使用JWT验证)
|
||||||
func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientToken string) bool {
|
func (s *tokenServiceJWT) Validate(ctx context.Context, accessToken, clientToken string) bool {
|
||||||
// 设置超时上下文
|
// 设置超时上下文
|
||||||
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if accessToken == "" {
|
if accessToken == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -189,13 +197,6 @@ func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientTok
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从Redis获取Token元数据
|
|
||||||
metadata, err := s.tokenStore.Retrieve(ctx, accessToken)
|
|
||||||
if err != nil {
|
|
||||||
// Token可能已过期或不存在
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找Client
|
// 查找Client
|
||||||
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
|
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,19 +209,18 @@ func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientTok
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证ClientToken(如果提供)
|
// 验证ClientToken(如果提供)
|
||||||
if clientToken != "" && metadata.ClientToken != clientToken {
|
if clientToken != "" && client.ClientToken != clientToken {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh 刷新Token(使用Version机制,Redis存储)
|
// Refresh 刷新Token(使用Version机制,无需删除旧Token)
|
||||||
func (s *tokenServiceRedis) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) {
|
func (s *tokenServiceJWT) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) {
|
||||||
// 设置超时上下文
|
// 设置超时上下文
|
||||||
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if accessToken == "" {
|
if accessToken == "" {
|
||||||
return "", "", errors.New("accessToken不能为空")
|
return "", "", errors.New("accessToken不能为空")
|
||||||
}
|
}
|
||||||
@@ -279,11 +279,6 @@ func (s *tokenServiceRedis) Refresh(ctx context.Context, accessToken, clientToke
|
|||||||
return "", "", fmt.Errorf("更新Client版本失败: %w", err)
|
return "", "", fmt.Errorf("更新Client版本失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除旧Token(从Redis)
|
|
||||||
if err := s.tokenStore.Delete(ctx, accessToken); err != nil {
|
|
||||||
s.logger.Warn("删除旧Token失败", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成Token过期时间
|
// 生成Token过期时间
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var expiresAt, staleAt time.Time
|
var expiresAt, staleAt time.Time
|
||||||
@@ -313,27 +308,30 @@ func (s *tokenServiceRedis) Refresh(ctx context.Context, accessToken, clientToke
|
|||||||
return "", "", fmt.Errorf("生成新AccessToken失败: %w", err)
|
return "", "", fmt.Errorf("生成新AccessToken失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存储新Token到Redis
|
// 保存新Token记录
|
||||||
ttl := expiresAt.Sub(now)
|
newToken := model.Token{
|
||||||
metadata := &auth.TokenMetadata{
|
AccessToken: newAccessToken,
|
||||||
UserID: client.UserID,
|
|
||||||
ProfileID: selectedProfileID,
|
|
||||||
ClientUUID: client.UUID,
|
|
||||||
ClientToken: client.ClientToken,
|
ClientToken: client.ClientToken,
|
||||||
|
UserID: client.UserID,
|
||||||
|
ProfileId: selectedProfileID,
|
||||||
Version: client.Version,
|
Version: client.Version,
|
||||||
CreatedAt: now.Unix(),
|
Usable: true,
|
||||||
|
IssueDate: now,
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
StaleAt: &staleAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.tokenStore.Store(ctx, newAccessToken, metadata, ttl); err != nil {
|
err = s.tokenRepo.Create(ctx, &newToken)
|
||||||
s.logger.Warn("存储新Token到Redis失败", zap.Error(err))
|
if err != nil {
|
||||||
|
s.logger.Warn("保存新Token记录失败,但JWT已生成", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("成功刷新Token", zap.Int64("userId", client.UserID), zap.Int("version", client.Version))
|
s.logger.Info("成功刷新Token", zap.Int64("userId", client.UserID), zap.Int("version", client.Version))
|
||||||
return newAccessToken, client.ClientToken, nil
|
return newAccessToken, client.ClientToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate 使Token失效(从Redis删除)
|
// Invalidate 使Token失效(通过增加Version)
|
||||||
func (s *tokenServiceRedis) Invalidate(ctx context.Context, accessToken string) {
|
func (s *tokenServiceJWT) Invalidate(ctx context.Context, accessToken string) {
|
||||||
// 设置超时上下文
|
// 设置超时上下文
|
||||||
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -349,7 +347,7 @@ func (s *tokenServiceRedis) Invalidate(ctx context.Context, accessToken string)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找Client并增加Version(失效所有旧Token)
|
// 查找Client并增加Version
|
||||||
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
|
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("无法找到对应的Client", zap.Error(err))
|
s.logger.Warn("无法找到对应的Client", zap.Error(err))
|
||||||
@@ -364,17 +362,11 @@ func (s *tokenServiceRedis) Invalidate(ctx context.Context, accessToken string)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从Redis删除Token
|
|
||||||
if err := s.tokenStore.Delete(ctx, accessToken); err != nil {
|
|
||||||
s.logger.Warn("从Redis删除Token失败", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("成功失效Token", zap.String("clientUUID", client.UUID), zap.Int("version", client.Version))
|
s.logger.Info("成功失效Token", zap.String("clientUUID", client.UUID), zap.Int("version", client.Version))
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateUserTokens 使用户所有Token失效(从Redis删除)
|
// InvalidateUserTokens 使用户所有Token失效
|
||||||
func (s *tokenServiceRedis) InvalidateUserTokens(ctx context.Context, userID int64) {
|
func (s *tokenServiceJWT) InvalidateUserTokens(ctx context.Context, userID int64) {
|
||||||
// 设置超时上下文
|
// 设置超时上下文
|
||||||
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -399,20 +391,15 @@ func (s *tokenServiceRedis) InvalidateUserTokens(ctx context.Context, userID int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从Redis删除用户所有Token
|
|
||||||
if err := s.tokenStore.DeleteByUserID(ctx, userID); err != nil {
|
|
||||||
s.logger.Error("从Redis删除用户Token失败", zap.Error(err), zap.Int64("userId", userID))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("成功失效用户所有Token", zap.Int64("userId", userID), zap.Int("clientCount", len(clients)))
|
s.logger.Info("成功失效用户所有Token", zap.Int64("userId", userID), zap.Int("clientCount", len(clients)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUUIDByAccessToken 从AccessToken获取UUID(通过JWT解析)
|
// GetUUIDByAccessToken 从AccessToken获取UUID(通过JWT解析)
|
||||||
func (s *tokenServiceRedis) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
|
func (s *tokenServiceJWT) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
|
||||||
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
|
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.New("accessToken无效")
|
// 如果JWT解析失败,尝试从数据库查询(向后兼容)
|
||||||
|
return s.tokenRepo.GetUUIDByAccessToken(ctx, accessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.ProfileID != "" {
|
if claims.ProfileID != "" {
|
||||||
@@ -433,10 +420,11 @@ func (s *tokenServiceRedis) GetUUIDByAccessToken(ctx context.Context, accessToke
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUserIDByAccessToken 从AccessToken获取UserID(通过JWT解析)
|
// GetUserIDByAccessToken 从AccessToken获取UserID(通过JWT解析)
|
||||||
func (s *tokenServiceRedis) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
|
func (s *tokenServiceJWT) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
|
||||||
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
|
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, errors.New("accessToken无效")
|
// 如果JWT解析失败,尝试从数据库查询(向后兼容)
|
||||||
|
return s.tokenRepo.GetUserIDByAccessToken(ctx, accessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从Client获取UserID
|
// 从Client获取UserID
|
||||||
@@ -453,8 +441,44 @@ func (s *tokenServiceRedis) GetUserIDByAccessToken(ctx context.Context, accessTo
|
|||||||
return client.UserID, nil
|
return client.UserID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateProfileByUserID 验证Profile是否属于用户
|
// 私有辅助方法
|
||||||
func (s *tokenServiceRedis) validateProfileByUserID(ctx context.Context, userID int64, UUID string) (bool, error) {
|
|
||||||
|
func (s *tokenServiceJWT) checkAndCleanupExcessTokens(ctx context.Context, userID int64) {
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为清理操作设置更长的超时时间
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, tokenExtendedTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tokens, err := s.tokenRepo.GetByUserID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("获取用户Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tokens) <= tokensMaxCount {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokensToDelete := make([]string, 0, len(tokens)-tokensMaxCount)
|
||||||
|
for i := tokensMaxCount; i < len(tokens); i++ {
|
||||||
|
tokensToDelete = append(tokensToDelete, tokens[i].AccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedCount, err := s.tokenRepo.BatchDelete(ctx, tokensToDelete)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("清理用户多余Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deletedCount > 0 {
|
||||||
|
s.logger.Info("成功清理用户多余Token", zap.Int64("userId", userID), zap.Int64("count", deletedCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tokenServiceJWT) validateProfileByUserID(ctx context.Context, userID int64, UUID string) (bool, error) {
|
||||||
if userID == 0 || UUID == "" {
|
if userID == 0 || UUID == "" {
|
||||||
return false, errors.New("用户ID或配置文件ID不能为空")
|
return false, errors.New("用户ID或配置文件ID不能为空")
|
||||||
}
|
}
|
||||||
@@ -468,3 +492,24 @@ func (s *tokenServiceRedis) validateProfileByUserID(ctx context.Context, userID
|
|||||||
}
|
}
|
||||||
return profile.UserID == userID, nil
|
return profile.UserID == userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClientFromToken 从Token获取Client信息(辅助方法)
|
||||||
|
func (s *tokenServiceJWT) GetClientFromToken(ctx context.Context, accessToken string, stalePolicy auth.StaleTokenPolicy) (*model.Client, error) {
|
||||||
|
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, stalePolicy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证Version
|
||||||
|
if claims.Version != client.Version {
|
||||||
|
return nil, errors.New("token版本不匹配")
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
512
internal/service/token_service_test.go
Normal file
512
internal/service/token_service_test.go
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTokenService_Constants 测试Token服务相关常量
|
||||||
|
func TestTokenService_Constants(t *testing.T) {
|
||||||
|
// 内部常量已私有化,通过服务行为间接测试
|
||||||
|
t.Skip("Token constants are now private - test through service behavior instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenService_Validation 测试Token验证逻辑
|
||||||
|
func TestTokenService_Validation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
accessToken string
|
||||||
|
wantValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "空token无效",
|
||||||
|
accessToken: "",
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非空token可能有效",
|
||||||
|
accessToken: "valid-token-string",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 测试空token检查逻辑
|
||||||
|
isValid := tt.accessToken != ""
|
||||||
|
if isValid != tt.wantValid {
|
||||||
|
t.Errorf("Token validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenService_ClientTokenLogic 测试ClientToken逻辑
|
||||||
|
func TestTokenService_ClientTokenLogic(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
clientToken string
|
||||||
|
shouldGenerate bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "空的clientToken应该生成新的",
|
||||||
|
clientToken: "",
|
||||||
|
shouldGenerate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非空的clientToken应该使用提供的",
|
||||||
|
clientToken: "existing-client-token",
|
||||||
|
shouldGenerate: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
shouldGenerate := tt.clientToken == ""
|
||||||
|
if shouldGenerate != tt.shouldGenerate {
|
||||||
|
t.Errorf("ClientToken logic failed: got %v, want %v", shouldGenerate, tt.shouldGenerate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenService_ProfileSelection 测试Profile选择逻辑
|
||||||
|
func TestTokenService_ProfileSelection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
profileCount int
|
||||||
|
shouldAutoSelect bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "只有一个profile时自动选择",
|
||||||
|
profileCount: 1,
|
||||||
|
shouldAutoSelect: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "多个profile时不自动选择",
|
||||||
|
profileCount: 2,
|
||||||
|
shouldAutoSelect: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "没有profile时不自动选择",
|
||||||
|
profileCount: 0,
|
||||||
|
shouldAutoSelect: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
shouldAutoSelect := tt.profileCount == 1
|
||||||
|
if shouldAutoSelect != tt.shouldAutoSelect {
|
||||||
|
t.Errorf("Profile selection logic failed: got %v, want %v", shouldAutoSelect, tt.shouldAutoSelect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenService_CleanupLogic 测试清理逻辑
|
||||||
|
func TestTokenService_CleanupLogic(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tokenCount int
|
||||||
|
maxCount int
|
||||||
|
shouldCleanup bool
|
||||||
|
cleanupCount int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "token数量未超过上限,不需要清理",
|
||||||
|
tokenCount: 5,
|
||||||
|
maxCount: 10,
|
||||||
|
shouldCleanup: false,
|
||||||
|
cleanupCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token数量超过上限,需要清理",
|
||||||
|
tokenCount: 15,
|
||||||
|
maxCount: 10,
|
||||||
|
shouldCleanup: true,
|
||||||
|
cleanupCount: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token数量等于上限,不需要清理",
|
||||||
|
tokenCount: 10,
|
||||||
|
maxCount: 10,
|
||||||
|
shouldCleanup: false,
|
||||||
|
cleanupCount: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
shouldCleanup := tt.tokenCount > tt.maxCount
|
||||||
|
if shouldCleanup != tt.shouldCleanup {
|
||||||
|
t.Errorf("Cleanup decision failed: got %v, want %v", shouldCleanup, tt.shouldCleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldCleanup {
|
||||||
|
expectedCleanupCount := tt.tokenCount - tt.maxCount
|
||||||
|
if expectedCleanupCount != tt.cleanupCount {
|
||||||
|
t.Errorf("Cleanup count failed: got %d, want %d", expectedCleanupCount, tt.cleanupCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenService_UserIDValidation 测试UserID验证
|
||||||
|
func TestTokenService_UserIDValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
userID int64
|
||||||
|
isValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "有效的UserID",
|
||||||
|
userID: 1,
|
||||||
|
isValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UserID为0时无效",
|
||||||
|
userID: 0,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "负数UserID无效",
|
||||||
|
userID: -1,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
isValid := tt.userID > 0
|
||||||
|
if isValid != tt.isValid {
|
||||||
|
t.Errorf("UserID validation failed: got %v, want %v", isValid, tt.isValid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 使用 Mock 的集成测试
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// TestTokenServiceImpl_Create 测试创建Token
|
||||||
|
func TestTokenServiceImpl_Create(t *testing.T) {
|
||||||
|
tokenRepo := NewMockTokenRepository()
|
||||||
|
profileRepo := NewMockProfileRepository()
|
||||||
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
// 预置Profile
|
||||||
|
testProfile := &model.Profile{
|
||||||
|
UUID: "test-profile-uuid",
|
||||||
|
UserID: 1,
|
||||||
|
Name: "TestProfile",
|
||||||
|
}
|
||||||
|
_ = profileRepo.Create(context.Background(), testProfile)
|
||||||
|
|
||||||
|
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
userID int64
|
||||||
|
uuid string
|
||||||
|
clientToken string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "正常创建Token(指定UUID)",
|
||||||
|
userID: 1,
|
||||||
|
uuid: "test-profile-uuid",
|
||||||
|
clientToken: "client-token-1",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "正常创建Token(空clientToken)",
|
||||||
|
userID: 1,
|
||||||
|
uuid: "test-profile-uuid",
|
||||||
|
clientToken: "",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _, accessToken, clientToken, err := tokenService.Create(ctx, tt.userID, tt.uuid, tt.clientToken)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Error("期望返回错误,但实际没有错误")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("不期望返回错误: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if accessToken == "" {
|
||||||
|
t.Error("accessToken不应为空")
|
||||||
|
}
|
||||||
|
if clientToken == "" {
|
||||||
|
t.Error("clientToken不应为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenServiceImpl_Validate 测试验证Token
|
||||||
|
func TestTokenServiceImpl_Validate(t *testing.T) {
|
||||||
|
tokenRepo := NewMockTokenRepository()
|
||||||
|
profileRepo := NewMockProfileRepository()
|
||||||
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
// 预置Token
|
||||||
|
testToken := &model.Token{
|
||||||
|
AccessToken: "valid-access-token",
|
||||||
|
ClientToken: "valid-client-token",
|
||||||
|
UserID: 1,
|
||||||
|
ProfileId: "test-profile-uuid",
|
||||||
|
Usable: true,
|
||||||
|
}
|
||||||
|
_ = tokenRepo.Create(context.Background(), testToken)
|
||||||
|
|
||||||
|
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
accessToken string
|
||||||
|
clientToken string
|
||||||
|
wantValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "有效Token(完全匹配)",
|
||||||
|
accessToken: "valid-access-token",
|
||||||
|
clientToken: "valid-client-token",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "有效Token(只检查accessToken)",
|
||||||
|
accessToken: "valid-access-token",
|
||||||
|
clientToken: "",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "无效Token(accessToken不存在)",
|
||||||
|
accessToken: "invalid-access-token",
|
||||||
|
clientToken: "",
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "无效Token(clientToken不匹配)",
|
||||||
|
accessToken: "valid-access-token",
|
||||||
|
clientToken: "wrong-client-token",
|
||||||
|
wantValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
isValid := tokenService.Validate(ctx, tt.accessToken, tt.clientToken)
|
||||||
|
|
||||||
|
if isValid != tt.wantValid {
|
||||||
|
t.Errorf("Token验证结果不匹配: got %v, want %v", isValid, tt.wantValid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenServiceImpl_Invalidate 测试注销Token
|
||||||
|
func TestTokenServiceImpl_Invalidate(t *testing.T) {
|
||||||
|
tokenRepo := NewMockTokenRepository()
|
||||||
|
profileRepo := NewMockProfileRepository()
|
||||||
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
// 预置Token
|
||||||
|
testToken := &model.Token{
|
||||||
|
AccessToken: "token-to-invalidate",
|
||||||
|
ClientToken: "client-token",
|
||||||
|
UserID: 1,
|
||||||
|
ProfileId: "test-profile-uuid",
|
||||||
|
Usable: true,
|
||||||
|
}
|
||||||
|
_ = tokenRepo.Create(context.Background(), testToken)
|
||||||
|
|
||||||
|
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 验证Token存在
|
||||||
|
isValid := tokenService.Validate(ctx, "token-to-invalidate", "")
|
||||||
|
if !isValid {
|
||||||
|
t.Error("Token应该有效")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注销Token
|
||||||
|
tokenService.Invalidate(ctx, "token-to-invalidate")
|
||||||
|
|
||||||
|
// 验证Token已失效(从repo中删除)
|
||||||
|
_, err := tokenRepo.FindByAccessToken(context.Background(), "token-to-invalidate")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Token应该已被删除")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenServiceImpl_InvalidateUserTokens 测试注销用户所有Token
|
||||||
|
func TestTokenServiceImpl_InvalidateUserTokens(t *testing.T) {
|
||||||
|
tokenRepo := NewMockTokenRepository()
|
||||||
|
profileRepo := NewMockProfileRepository()
|
||||||
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
// 预置多个Token
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
_ = tokenRepo.Create(context.Background(), &model.Token{
|
||||||
|
AccessToken: fmt.Sprintf("user1-token-%d", i),
|
||||||
|
ClientToken: "client-token",
|
||||||
|
UserID: 1,
|
||||||
|
ProfileId: "test-profile-uuid",
|
||||||
|
Usable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = tokenRepo.Create(context.Background(), &model.Token{
|
||||||
|
AccessToken: "user2-token-1",
|
||||||
|
ClientToken: "client-token",
|
||||||
|
UserID: 2,
|
||||||
|
ProfileId: "test-profile-uuid-2",
|
||||||
|
Usable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 注销用户1的所有Token
|
||||||
|
tokenService.InvalidateUserTokens(ctx, 1)
|
||||||
|
|
||||||
|
// 验证用户1的Token已失效
|
||||||
|
tokens, _ := tokenRepo.GetByUserID(context.Background(), 1)
|
||||||
|
if len(tokens) > 0 {
|
||||||
|
t.Errorf("用户1的Token应该全部被删除,但还剩 %d 个", len(tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户2的Token仍然存在
|
||||||
|
tokens2, _ := tokenRepo.GetByUserID(context.Background(), 2)
|
||||||
|
if len(tokens2) != 1 {
|
||||||
|
t.Errorf("用户2的Token应该仍然存在,期望1个,实际 %d 个", len(tokens2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenServiceImpl_Refresh 覆盖 Refresh 的主要分支
|
||||||
|
func TestTokenServiceImpl_Refresh(t *testing.T) {
|
||||||
|
tokenRepo := NewMockTokenRepository()
|
||||||
|
profileRepo := NewMockProfileRepository()
|
||||||
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
// 预置 Profile 与 Token
|
||||||
|
profile := &model.Profile{
|
||||||
|
UUID: "profile-uuid",
|
||||||
|
UserID: 1,
|
||||||
|
}
|
||||||
|
_ = profileRepo.Create(context.Background(), profile)
|
||||||
|
|
||||||
|
oldToken := &model.Token{
|
||||||
|
AccessToken: "old-token",
|
||||||
|
ClientToken: "client-token",
|
||||||
|
UserID: 1,
|
||||||
|
ProfileId: "",
|
||||||
|
Usable: true,
|
||||||
|
}
|
||||||
|
_ = tokenRepo.Create(context.Background(), oldToken)
|
||||||
|
|
||||||
|
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 正常刷新,不指定 profile
|
||||||
|
newAccess, client, err := tokenService.Refresh(ctx, "old-token", "client-token", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Refresh 正常情况失败: %v", err)
|
||||||
|
}
|
||||||
|
if newAccess == "" || client != "client-token" {
|
||||||
|
t.Fatalf("Refresh 返回值异常: access=%s, client=%s", newAccess, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// accessToken 为空
|
||||||
|
if _, _, err := tokenService.Refresh(ctx, "", "client-token", ""); err == nil {
|
||||||
|
t.Fatalf("Refresh 在 accessToken 为空时应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenServiceImpl_GetByAccessToken 封装 GetUUIDByAccessToken / GetUserIDByAccessToken
|
||||||
|
func TestTokenServiceImpl_GetByAccessToken(t *testing.T) {
|
||||||
|
tokenRepo := NewMockTokenRepository()
|
||||||
|
profileRepo := NewMockProfileRepository()
|
||||||
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
token := &model.Token{
|
||||||
|
AccessToken: "token-1",
|
||||||
|
UserID: 42,
|
||||||
|
ProfileId: "profile-42",
|
||||||
|
Usable: true,
|
||||||
|
}
|
||||||
|
_ = tokenRepo.Create(context.Background(), token)
|
||||||
|
|
||||||
|
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
uuid, err := tokenService.GetUUIDByAccessToken(ctx, "token-1")
|
||||||
|
if err != nil || uuid != "profile-42" {
|
||||||
|
t.Fatalf("GetUUIDByAccessToken 返回错误: uuid=%s, err=%v", uuid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, err := tokenService.GetUserIDByAccessToken(ctx, "token-1")
|
||||||
|
if err != nil || uid != 42 {
|
||||||
|
t.Fatalf("GetUserIDByAccessToken 返回错误: uid=%d, err=%v", uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenServiceImpl_validateProfileByUserID 直接测试内部校验逻辑
|
||||||
|
func TestTokenServiceImpl_validateProfileByUserID(t *testing.T) {
|
||||||
|
tokenRepo := NewMockTokenRepository()
|
||||||
|
profileRepo := NewMockProfileRepository()
|
||||||
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
svc := &tokenService{
|
||||||
|
tokenRepo: tokenRepo,
|
||||||
|
profileRepo: profileRepo,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预置 Profile
|
||||||
|
profile := &model.Profile{
|
||||||
|
UUID: "p-1",
|
||||||
|
UserID: 1,
|
||||||
|
}
|
||||||
|
_ = profileRepo.Create(context.Background(), profile)
|
||||||
|
|
||||||
|
// 参数非法
|
||||||
|
if ok, err := svc.validateProfileByUserID(context.Background(), 0, ""); err == nil || ok {
|
||||||
|
t.Fatalf("validateProfileByUserID 在参数非法时应返回错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile 不存在
|
||||||
|
if ok, err := svc.validateProfileByUserID(context.Background(), 1, "not-exists"); err == nil || ok {
|
||||||
|
t.Fatalf("validateProfileByUserID 在 Profile 不存在时应返回错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户与 Profile 匹配
|
||||||
|
if ok, err := svc.validateProfileByUserID(context.Background(), 1, "p-1"); err != nil || !ok {
|
||||||
|
t.Fatalf("validateProfileByUserID 匹配时应返回 true, err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户与 Profile 不匹配
|
||||||
|
if ok, err := svc.validateProfileByUserID(context.Background(), 2, "p-1"); err != nil || ok {
|
||||||
|
t.Fatalf("validateProfileByUserID 不匹配时应返回 false, err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"carrotskin/pkg/storage"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileType 文件类型枚举
|
|
||||||
type FileType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
FileTypeAvatar FileType = "avatar"
|
|
||||||
FileTypeTexture FileType = "texture"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UploadConfig 上传配置
|
|
||||||
type UploadConfig struct {
|
|
||||||
AllowedExts map[string]bool // 允许的文件扩展名
|
|
||||||
MinSize int64 // 最小文件大小(字节)
|
|
||||||
MaxSize int64 // 最大文件大小(字节)
|
|
||||||
Expires time.Duration // URL过期时间
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadService UploadService的实现
|
|
||||||
type uploadService struct {
|
|
||||||
storage *storage.StorageClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUploadService 创建UploadService实例
|
|
||||||
func NewUploadService(storageClient *storage.StorageClient) UploadService {
|
|
||||||
return &uploadService{
|
|
||||||
storage: storageClient,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAvatarUploadURL 生成头像上传URL
|
|
||||||
func (s *uploadService) GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error) {
|
|
||||||
// 1. 验证文件名
|
|
||||||
if err := ValidateFileName(fileName, FileTypeAvatar); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 获取上传配置
|
|
||||||
uploadConfig := GetUploadConfig(FileTypeAvatar)
|
|
||||||
|
|
||||||
// 3. 获取存储桶名称
|
|
||||||
bucketName, err := s.storage.GetBucket("avatars")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取存储桶失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 生成对象名称(路径)
|
|
||||||
// 格式: user_{userId}/timestamp_{originalFileName}
|
|
||||||
timestamp := time.Now().Format("20060102150405")
|
|
||||||
objectName := fmt.Sprintf("user_%d/%s_%s", userID, timestamp, fileName)
|
|
||||||
|
|
||||||
// 5. 生成预签名POST URL (使用存储客户端内置的 PublicURL)
|
|
||||||
result, err := s.storage.GeneratePresignedPostURL(
|
|
||||||
ctx,
|
|
||||||
bucketName,
|
|
||||||
objectName,
|
|
||||||
uploadConfig.MinSize,
|
|
||||||
uploadConfig.MaxSize,
|
|
||||||
uploadConfig.Expires,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("生成上传URL失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateTextureUploadURL 生成材质上传URL
|
|
||||||
func (s *uploadService) GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error) {
|
|
||||||
// 1. 验证文件名
|
|
||||||
if err := ValidateFileName(fileName, FileTypeTexture); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 验证材质类型
|
|
||||||
if textureType != "SKIN" && textureType != "CAPE" {
|
|
||||||
return nil, fmt.Errorf("无效的材质类型: %s", textureType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 获取上传配置
|
|
||||||
uploadConfig := GetUploadConfig(FileTypeTexture)
|
|
||||||
|
|
||||||
// 4. 获取存储桶名称
|
|
||||||
bucketName, err := s.storage.GetBucket("textures")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取存储桶失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 生成对象名称(路径)
|
|
||||||
// 格式: user_{userId}/{textureType}/timestamp_{originalFileName}
|
|
||||||
timestamp := time.Now().Format("20060102150405")
|
|
||||||
textureTypeFolder := strings.ToLower(textureType)
|
|
||||||
objectName := fmt.Sprintf("user_%d/%s/%s_%s", userID, textureTypeFolder, timestamp, fileName)
|
|
||||||
|
|
||||||
// 6. 生成预签名POST URL (使用存储客户端内置的 PublicURL)
|
|
||||||
result, err := s.storage.GeneratePresignedPostURL(
|
|
||||||
ctx,
|
|
||||||
bucketName,
|
|
||||||
objectName,
|
|
||||||
uploadConfig.MinSize,
|
|
||||||
uploadConfig.MaxSize,
|
|
||||||
uploadConfig.Expires,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("生成上传URL失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUploadConfig 根据文件类型获取上传配置
|
|
||||||
func GetUploadConfig(fileType FileType) *UploadConfig {
|
|
||||||
switch fileType {
|
|
||||||
case FileTypeAvatar:
|
|
||||||
return &UploadConfig{
|
|
||||||
AllowedExts: map[string]bool{
|
|
||||||
".jpg": true,
|
|
||||||
".jpeg": true,
|
|
||||||
".png": true,
|
|
||||||
".gif": true,
|
|
||||||
".webp": true,
|
|
||||||
},
|
|
||||||
MinSize: 512, // 512B
|
|
||||||
MaxSize: 5 * 1024 * 1024, // 5MB
|
|
||||||
Expires: 15 * time.Minute,
|
|
||||||
}
|
|
||||||
case FileTypeTexture:
|
|
||||||
return &UploadConfig{
|
|
||||||
AllowedExts: map[string]bool{
|
|
||||||
".png": true,
|
|
||||||
},
|
|
||||||
MinSize: 512, // 512B
|
|
||||||
MaxSize: 10 * 1024 * 1024, // 10MB
|
|
||||||
Expires: 15 * time.Minute,
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateFileName 验证文件名
|
|
||||||
func ValidateFileName(fileName string, fileType FileType) error {
|
|
||||||
if fileName == "" {
|
|
||||||
return fmt.Errorf("文件名不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadConfig := GetUploadConfig(fileType)
|
|
||||||
if uploadConfig == nil {
|
|
||||||
return fmt.Errorf("不支持的文件类型")
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(fileName))
|
|
||||||
if !uploadConfig.AllowedExts[ext] {
|
|
||||||
return fmt.Errorf("不支持的文件格式: %s", ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"carrotskin/pkg/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestUploadService_FileTypes 测试文件类型常量
|
|
||||||
func TestUploadService_FileTypes(t *testing.T) {
|
|
||||||
if FileTypeAvatar == "" {
|
|
||||||
t.Error("FileTypeAvatar should not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if FileTypeTexture == "" {
|
|
||||||
t.Error("FileTypeTexture should not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if FileTypeAvatar == FileTypeTexture {
|
|
||||||
t.Error("FileTypeAvatar and FileTypeTexture should be different")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetUploadConfig 测试获取上传配置
|
|
||||||
func TestGetUploadConfig(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
fileType FileType
|
|
||||||
wantConfig bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "头像类型返回配置",
|
|
||||||
fileType: FileTypeAvatar,
|
|
||||||
wantConfig: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "材质类型返回配置",
|
|
||||||
fileType: FileTypeTexture,
|
|
||||||
wantConfig: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "无效类型返回nil",
|
|
||||||
fileType: FileType("invalid"),
|
|
||||||
wantConfig: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
config := GetUploadConfig(tt.fileType)
|
|
||||||
hasConfig := config != nil
|
|
||||||
if hasConfig != tt.wantConfig {
|
|
||||||
t.Errorf("GetUploadConfig() = %v, want %v", hasConfig, tt.wantConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
if config != nil {
|
|
||||||
// 验证配置字段
|
|
||||||
if config.MinSize <= 0 {
|
|
||||||
t.Error("MinSize should be greater than 0")
|
|
||||||
}
|
|
||||||
if config.MaxSize <= 0 {
|
|
||||||
t.Error("MaxSize should be greater than 0")
|
|
||||||
}
|
|
||||||
if config.MaxSize < config.MinSize {
|
|
||||||
t.Error("MaxSize should be greater than or equal to MinSize")
|
|
||||||
}
|
|
||||||
if config.Expires <= 0 {
|
|
||||||
t.Error("Expires should be greater than 0")
|
|
||||||
}
|
|
||||||
if len(config.AllowedExts) == 0 {
|
|
||||||
t.Error("AllowedExts should not be empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetUploadConfig_AvatarConfig 测试头像配置详情
|
|
||||||
func TestGetUploadConfig_AvatarConfig(t *testing.T) {
|
|
||||||
config := GetUploadConfig(FileTypeAvatar)
|
|
||||||
if config == nil {
|
|
||||||
t.Fatal("Avatar config should not be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证允许的扩展名
|
|
||||||
expectedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
|
||||||
for _, ext := range expectedExts {
|
|
||||||
if !config.AllowedExts[ext] {
|
|
||||||
t.Errorf("Avatar config should allow %s extension", ext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证文件大小限制
|
|
||||||
if config.MinSize != 512 {
|
|
||||||
t.Errorf("Avatar MinSize = %d, want 512", config.MinSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.MaxSize != 5*1024*1024 {
|
|
||||||
t.Errorf("Avatar MaxSize = %d, want 5MB", config.MaxSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证过期时间
|
|
||||||
if config.Expires != 15*time.Minute {
|
|
||||||
t.Errorf("Avatar Expires = %v, want 15 minutes", config.Expires)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetUploadConfig_TextureConfig 测试材质配置详情
|
|
||||||
func TestGetUploadConfig_TextureConfig(t *testing.T) {
|
|
||||||
config := GetUploadConfig(FileTypeTexture)
|
|
||||||
if config == nil {
|
|
||||||
t.Fatal("Texture config should not be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证允许的扩展名(材质只允许PNG)
|
|
||||||
if !config.AllowedExts[".png"] {
|
|
||||||
t.Error("Texture config should allow .png extension")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证文件大小限制
|
|
||||||
if config.MinSize != 512 {
|
|
||||||
t.Errorf("Texture MinSize = %d, want 512", config.MinSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.MaxSize != 10*1024*1024 {
|
|
||||||
t.Errorf("Texture MaxSize = %d, want 10MB", config.MaxSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证过期时间
|
|
||||||
if config.Expires != 15*time.Minute {
|
|
||||||
t.Errorf("Texture Expires = %v, want 15 minutes", config.Expires)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateFileName 测试文件名验证
|
|
||||||
func TestValidateFileName(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
fileName string
|
|
||||||
fileType FileType
|
|
||||||
wantErr bool
|
|
||||||
errContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "有效的头像文件名",
|
|
||||||
fileName: "avatar.png",
|
|
||||||
fileType: FileTypeAvatar,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "有效的材质文件名",
|
|
||||||
fileName: "texture.png",
|
|
||||||
fileType: FileTypeTexture,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "文件名为空",
|
|
||||||
fileName: "",
|
|
||||||
fileType: FileTypeAvatar,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "文件名不能为空",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "不支持的文件扩展名",
|
|
||||||
fileName: "file.txt",
|
|
||||||
fileType: FileTypeAvatar,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "不支持的文件格式",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "无效的文件类型",
|
|
||||||
fileName: "file.png",
|
|
||||||
fileType: FileType("invalid"),
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "不支持的文件类型",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
err := ValidateFileName(tt.fileName, tt.fileType)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("ValidateFileName() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if tt.wantErr && tt.errContains != "" {
|
|
||||||
if err == nil || !strings.Contains(err.Error(), tt.errContains) {
|
|
||||||
t.Errorf("ValidateFileName() error = %v, should contain %s", err, tt.errContains)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateFileName_Extensions 测试各种扩展名
|
|
||||||
func TestValidateFileName_Extensions(t *testing.T) {
|
|
||||||
avatarExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
|
||||||
for _, ext := range avatarExts {
|
|
||||||
fileName := "test" + ext
|
|
||||||
err := ValidateFileName(fileName, FileTypeAvatar)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Avatar file with %s extension should be valid, got error: %v", ext, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 材质只支持PNG
|
|
||||||
textureExts := []string{".png"}
|
|
||||||
for _, ext := range textureExts {
|
|
||||||
fileName := "test" + ext
|
|
||||||
err := ValidateFileName(fileName, FileTypeTexture)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Texture file with %s extension should be valid, got error: %v", ext, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试不支持的扩展名
|
|
||||||
invalidExts := []string{".txt", ".pdf", ".doc"}
|
|
||||||
for _, ext := range invalidExts {
|
|
||||||
fileName := "test" + ext
|
|
||||||
err := ValidateFileName(fileName, FileTypeAvatar)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Avatar file with %s extension should be invalid", ext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateFileName_CaseInsensitive 测试扩展名大小写不敏感
|
|
||||||
func TestValidateFileName_CaseInsensitive(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
fileName string
|
|
||||||
fileType FileType
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"test.PNG", FileTypeAvatar, false},
|
|
||||||
{"test.JPG", FileTypeAvatar, false},
|
|
||||||
{"test.JPEG", FileTypeAvatar, false},
|
|
||||||
{"test.GIF", FileTypeAvatar, false},
|
|
||||||
{"test.WEBP", FileTypeAvatar, false},
|
|
||||||
{"test.PnG", FileTypeTexture, false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.fileName, func(t *testing.T) {
|
|
||||||
err := ValidateFileName(tc.fileName, tc.fileType)
|
|
||||||
if (err != nil) != tc.wantErr {
|
|
||||||
t.Errorf("ValidateFileName(%s, %s) error = %v, wantErr %v", tc.fileName, tc.fileType, err, tc.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUploadConfig_Structure 测试UploadConfig结构
|
|
||||||
func TestUploadConfig_Structure(t *testing.T) {
|
|
||||||
config := &UploadConfig{
|
|
||||||
AllowedExts: map[string]bool{
|
|
||||||
".png": true,
|
|
||||||
},
|
|
||||||
MinSize: 512,
|
|
||||||
MaxSize: 5 * 1024 * 1024,
|
|
||||||
Expires: 15 * time.Minute,
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.AllowedExts == nil {
|
|
||||||
t.Error("AllowedExts should not be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.MinSize <= 0 {
|
|
||||||
t.Error("MinSize should be greater than 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.MaxSize <= config.MinSize {
|
|
||||||
t.Error("MaxSize should be greater than MinSize")
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Expires <= 0 {
|
|
||||||
t.Error("Expires should be greater than 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockStorageClient 用于单元测试的简单存储客户端假实现
|
|
||||||
// 注意:这里只声明与 upload_service 使用到的方法,避免依赖真实 MinIO 客户端
|
|
||||||
type mockStorageClient struct {
|
|
||||||
getBucketFn func(name string) (string, error)
|
|
||||||
generatePresignedPostURLFn func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockStorageClient) GetBucket(name string) (string, error) {
|
|
||||||
if m.getBucketFn != nil {
|
|
||||||
return m.getBucketFn(name)
|
|
||||||
}
|
|
||||||
return "", errors.New("GetBucket not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockStorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
|
|
||||||
if m.generatePresignedPostURLFn != nil {
|
|
||||||
return m.generatePresignedPostURLFn(ctx, bucketName, objectName, minSize, maxSize, expires)
|
|
||||||
}
|
|
||||||
return nil, errors.New("GeneratePresignedPostURL not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGenerateAvatarUploadURL_Success 测试头像上传URL生成成功
|
|
||||||
func TestGenerateAvatarUploadURL_Success(t *testing.T) {
|
|
||||||
// 由于 mockStorageClient 类型不匹配,跳过该测试
|
|
||||||
t.Skip("This test requires refactoring to work with the new service architecture")
|
|
||||||
|
|
||||||
_ = &mockStorageClient{
|
|
||||||
getBucketFn: func(name string) (string, error) {
|
|
||||||
if name != "avatars" {
|
|
||||||
t.Fatalf("unexpected bucket name: %s", name)
|
|
||||||
}
|
|
||||||
return "avatars-bucket", nil
|
|
||||||
},
|
|
||||||
generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
|
|
||||||
if bucketName != "avatars-bucket" {
|
|
||||||
t.Fatalf("unexpected bucketName: %s", bucketName)
|
|
||||||
}
|
|
||||||
if !strings.Contains(objectName, "user_") {
|
|
||||||
t.Fatalf("objectName should contain user_ prefix, got: %s", objectName)
|
|
||||||
}
|
|
||||||
if !strings.Contains(objectName, "avatar.png") {
|
|
||||||
t.Fatalf("objectName should contain original file name, got: %s", objectName)
|
|
||||||
}
|
|
||||||
// 检查大小与过期时间传递
|
|
||||||
if minSize != 512 {
|
|
||||||
t.Fatalf("minSize = %d, want 512", minSize)
|
|
||||||
}
|
|
||||||
if maxSize != 5*1024*1024 {
|
|
||||||
t.Fatalf("maxSize = %d, want 5MB", maxSize)
|
|
||||||
}
|
|
||||||
if expires != 15*time.Minute {
|
|
||||||
t.Fatalf("expires = %v, want 15m", expires)
|
|
||||||
}
|
|
||||||
return &storage.PresignedPostPolicyResult{
|
|
||||||
PostURL: "http://example.com/upload",
|
|
||||||
FormData: map[string]string{"key": objectName},
|
|
||||||
FileURL: "http://example.com/file/" + objectName,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGenerateTextureUploadURL_Success 测试材质上传URL生成成功(SKIN/CAPE)
|
|
||||||
func TestGenerateTextureUploadURL_Success(t *testing.T) {
|
|
||||||
// 由于 mockStorageClient 类型不匹配,跳过该测试
|
|
||||||
t.Skip("This test requires refactoring to work with the new service architecture")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
textureType string
|
|
||||||
}{
|
|
||||||
{"SKIN 材质", "SKIN"},
|
|
||||||
{"CAPE 材质", "CAPE"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
_ = &mockStorageClient{
|
|
||||||
getBucketFn: func(name string) (string, error) {
|
|
||||||
if name != "textures" {
|
|
||||||
t.Fatalf("unexpected bucket name: %s", name)
|
|
||||||
}
|
|
||||||
return "textures-bucket", nil
|
|
||||||
},
|
|
||||||
generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
|
|
||||||
if bucketName != "textures-bucket" {
|
|
||||||
t.Fatalf("unexpected bucketName: %s", bucketName)
|
|
||||||
}
|
|
||||||
if !strings.Contains(objectName, "texture.png") {
|
|
||||||
t.Fatalf("objectName should contain original file name, got: %s", objectName)
|
|
||||||
}
|
|
||||||
if !strings.Contains(objectName, "/"+strings.ToLower(tt.textureType)+"/") {
|
|
||||||
t.Fatalf("objectName should contain texture type folder, got: %s", objectName)
|
|
||||||
}
|
|
||||||
return &storage.PresignedPostPolicyResult{
|
|
||||||
PostURL: "http://example.com/upload",
|
|
||||||
FormData: map[string]string{"key": objectName},
|
|
||||||
FileURL: "http://example.com/file/" + objectName,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +19,7 @@ import (
|
|||||||
"carrotskin/pkg/config"
|
"carrotskin/pkg/config"
|
||||||
"carrotskin/pkg/database"
|
"carrotskin/pkg/database"
|
||||||
"carrotskin/pkg/redis"
|
"carrotskin/pkg/redis"
|
||||||
|
"carrotskin/pkg/storage"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -28,6 +33,7 @@ type userService struct {
|
|||||||
cache *database.CacheManager
|
cache *database.CacheManager
|
||||||
cacheKeys *database.CacheKeyBuilder
|
cacheKeys *database.CacheKeyBuilder
|
||||||
cacheInv *database.CacheInvalidator
|
cacheInv *database.CacheInvalidator
|
||||||
|
storage *storage.StorageClient
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +44,7 @@ func NewUserService(
|
|||||||
jwtService *auth.JWTService,
|
jwtService *auth.JWTService,
|
||||||
redisClient *redis.Client,
|
redisClient *redis.Client,
|
||||||
cacheManager *database.CacheManager,
|
cacheManager *database.CacheManager,
|
||||||
|
storageClient *storage.StorageClient,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) UserService {
|
) UserService {
|
||||||
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
|
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
|
||||||
@@ -50,6 +57,7 @@ func NewUserService(
|
|||||||
cache: cacheManager,
|
cache: cacheManager,
|
||||||
cacheKeys: database.NewCacheKeyBuilder(""),
|
cacheKeys: database.NewCacheKeyBuilder(""),
|
||||||
cacheInv: database.NewCacheInvalidator(cacheManager),
|
cacheInv: database.NewCacheInvalidator(cacheManager),
|
||||||
|
storage: storageClient,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +191,7 @@ func (s *userService) GetByID(ctx context.Context, id int64) (*model.User, error
|
|||||||
cacheKey := s.cacheKeys.User(id)
|
cacheKey := s.cacheKeys.User(id)
|
||||||
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
|
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
|
||||||
return s.userRepo.FindByID(ctx, id)
|
return s.userRepo.FindByID(ctx, id)
|
||||||
}, s.cache.Policy.UserTTL)
|
}, 5*time.Minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userService) GetByEmail(ctx context.Context, email string) (*model.User, error) {
|
func (s *userService) GetByEmail(ctx context.Context, email string) (*model.User, error) {
|
||||||
@@ -191,7 +199,7 @@ func (s *userService) GetByEmail(ctx context.Context, email string) (*model.User
|
|||||||
cacheKey := s.cacheKeys.UserByEmail(email)
|
cacheKey := s.cacheKeys.UserByEmail(email)
|
||||||
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
|
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
|
||||||
return s.userRepo.FindByEmail(ctx, email)
|
return s.userRepo.FindByEmail(ctx, email)
|
||||||
}, s.cache.Policy.UserEmailTTL)
|
}, 5*time.Minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userService) UpdateInfo(ctx context.Context, user *model.User) error {
|
func (s *userService) UpdateInfo(ctx context.Context, user *model.User) error {
|
||||||
@@ -347,6 +355,67 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e
|
|||||||
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains)
|
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) {
|
||||||
|
// 验证文件大小
|
||||||
|
fileSize := len(fileData)
|
||||||
|
const minSize = 512 // 512B
|
||||||
|
const maxSize = 5 * 1024 * 1024 // 5MB
|
||||||
|
if int64(fileSize) < minSize || int64(fileSize) > maxSize {
|
||||||
|
return "", fmt.Errorf("文件大小必须在 %d 到 %d 字节之间", minSize, maxSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件扩展名
|
||||||
|
ext := strings.ToLower(filepath.Ext(fileName))
|
||||||
|
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
|
||||||
|
if !allowedExts[ext] {
|
||||||
|
return "", fmt.Errorf("不支持的文件格式: %s,仅支持 jpg/jpeg/png/gif/webp", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查存储服务
|
||||||
|
if s.storage == nil {
|
||||||
|
return "", errors.New("存储服务不可用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算文件哈希
|
||||||
|
hashBytes := sha256.Sum256(fileData)
|
||||||
|
hash := hex.EncodeToString(hashBytes[:])
|
||||||
|
|
||||||
|
// 获取存储桶
|
||||||
|
bucketName, err := s.storage.GetBucket("avatars")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取存储桶失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成对象路径: avatars/{hash[:2]}/{hash[2:4]}/{hash}{ext}
|
||||||
|
objectName := fmt.Sprintf("%s/%s/%s%s", hash[:2], hash[2:4], hash, ext)
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
reader := bytes.NewReader(fileData)
|
||||||
|
contentType := "image/" + strings.TrimPrefix(ext, ".")
|
||||||
|
if ext == ".jpg" {
|
||||||
|
contentType = "image/jpeg"
|
||||||
|
}
|
||||||
|
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(fileSize), contentType); err != nil {
|
||||||
|
return "", fmt.Errorf("上传文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建文件URL
|
||||||
|
avatarURL := s.storage.BuildFileURL(bucketName, objectName)
|
||||||
|
|
||||||
|
// 更新用户头像
|
||||||
|
if err := s.UpdateAvatar(ctx, userID, avatarURL); err != nil {
|
||||||
|
return "", fmt.Errorf("更新用户头像失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("上传头像成功",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("hash", hash),
|
||||||
|
zap.String("url", avatarURL),
|
||||||
|
)
|
||||||
|
|
||||||
|
return avatarURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *userService) GetMaxProfilesPerUser() int {
|
func (s *userService) GetMaxProfilesPerUser() int {
|
||||||
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
|
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
|
||||||
if err != nil || config == nil {
|
if err != nil || config == nil {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestUserServiceImpl_Register(t *testing.T) {
|
|||||||
// 初始化Service
|
// 初始化Service
|
||||||
// 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们
|
// 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), testUser)
|
_ = userRepo.Create(context.Background(), testUser)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user)
|
_ = userRepo.Create(context.Background(), user)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user)
|
_ = userRepo.Create(context.Background(), user)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user)
|
_ = userRepo.Create(context.Background(), user)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user2)
|
_ = userRepo.Create(context.Background(), user2)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
|||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -381,7 +381,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
|
|||||||
|
|
||||||
// 未配置时走默认值
|
// 未配置时走默认值
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
if got := userService.GetMaxProfilesPerUser(); got != 5 {
|
if got := userService.GetMaxProfilesPerUser(); got != 5 {
|
||||||
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type yggdrasilServiceComposite struct {
|
|||||||
serializationService SerializationService
|
serializationService SerializationService
|
||||||
certificateService CertificateService
|
certificateService CertificateService
|
||||||
profileRepo repository.ProfileRepository
|
profileRepo repository.ProfileRepository
|
||||||
tokenService TokenService // 使用TokenService接口,不直接依赖TokenRepository
|
tokenRepo repository.TokenRepository
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +31,11 @@ func NewYggdrasilServiceComposite(
|
|||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
userRepo repository.UserRepository,
|
userRepo repository.UserRepository,
|
||||||
profileRepo repository.ProfileRepository,
|
profileRepo repository.ProfileRepository,
|
||||||
|
tokenRepo repository.TokenRepository,
|
||||||
yggdrasilRepo repository.YggdrasilRepository,
|
yggdrasilRepo repository.YggdrasilRepository,
|
||||||
signatureService *SignatureService,
|
signatureService *SignatureService,
|
||||||
redisClient *redis.Client,
|
redisClient *redis.Client,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
tokenService TokenService, // 新增:TokenService接口
|
|
||||||
) YggdrasilService {
|
) YggdrasilService {
|
||||||
// 创建各个专门的服务
|
// 创建各个专门的服务
|
||||||
authService := NewYggdrasilAuthService(db, userRepo, yggdrasilRepo, logger)
|
authService := NewYggdrasilAuthService(db, userRepo, yggdrasilRepo, logger)
|
||||||
@@ -53,7 +53,7 @@ func NewYggdrasilServiceComposite(
|
|||||||
serializationService: serializationService,
|
serializationService: serializationService,
|
||||||
certificateService: certificateService,
|
certificateService: certificateService,
|
||||||
profileRepo: profileRepo,
|
profileRepo: profileRepo,
|
||||||
tokenService: tokenService,
|
tokenRepo: tokenRepo,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,8 +75,8 @@ func (s *yggdrasilServiceComposite) ResetYggdrasilPassword(ctx context.Context,
|
|||||||
|
|
||||||
// JoinServer 加入服务器
|
// JoinServer 加入服务器
|
||||||
func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, accessToken, selectedProfile, ip string) error {
|
func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, accessToken, selectedProfile, ip string) error {
|
||||||
// 通过TokenService验证Token并获取UUID
|
// 验证Token
|
||||||
uuid, err := s.tokenService.GetUUIDByAccessToken(ctx, accessToken)
|
token, err := s.tokenRepo.FindByAccessToken(ctx, accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("验证Token失败",
|
s.logger.Error("验证Token失败",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -87,7 +87,7 @@ func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, ac
|
|||||||
|
|
||||||
// 格式化UUID并验证与Token关联的配置文件
|
// 格式化UUID并验证与Token关联的配置文件
|
||||||
formattedProfile := utils.FormatUUID(selectedProfile)
|
formattedProfile := utils.FormatUUID(selectedProfile)
|
||||||
if uuid != formattedProfile {
|
if token.ProfileId != formattedProfile {
|
||||||
return errors.New("selectedProfile与Token不匹配")
|
return errors.New("selectedProfile与Token不匹配")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
package task
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math/rand"
|
|
||||||
"runtime/debug"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Task 定义可调度任务
|
|
||||||
type Task interface {
|
|
||||||
Name() string
|
|
||||||
Interval() time.Duration
|
|
||||||
Run(ctx context.Context) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runner 简单的周期任务调度器
|
|
||||||
type Runner struct {
|
|
||||||
tasks []Task
|
|
||||||
logger *zap.Logger
|
|
||||||
wg sync.WaitGroup
|
|
||||||
startImmediately bool
|
|
||||||
jitterPercent float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRunner 创建任务调度器
|
|
||||||
func NewRunner(logger *zap.Logger, tasks ...Task) *Runner {
|
|
||||||
return NewRunnerWithOptions(logger, tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunnerOption 运行器配置项
|
|
||||||
type RunnerOption func(r *Runner)
|
|
||||||
|
|
||||||
// WithStartImmediately 是否启动后立即执行一次(默认 true)
|
|
||||||
func WithStartImmediately(start bool) RunnerOption {
|
|
||||||
return func(r *Runner) {
|
|
||||||
r.startImmediately = start
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithJitter 为执行间隔增加 0~percent 之间的随机抖动(percent=0 关闭,默认0)
|
|
||||||
// 可降低多个任务同时触发的概率
|
|
||||||
func WithJitter(percent float64) RunnerOption {
|
|
||||||
return func(r *Runner) {
|
|
||||||
if percent < 0 {
|
|
||||||
percent = 0
|
|
||||||
}
|
|
||||||
r.jitterPercent = percent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRunnerWithOptions 支持可选配置的创建函数
|
|
||||||
func NewRunnerWithOptions(logger *zap.Logger, tasks []Task, opts ...RunnerOption) *Runner {
|
|
||||||
r := &Runner{
|
|
||||||
tasks: tasks,
|
|
||||||
logger: logger,
|
|
||||||
startImmediately: true,
|
|
||||||
jitterPercent: 0,
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(r)
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start 启动所有任务(异步)
|
|
||||||
func (r *Runner) Start(ctx context.Context) {
|
|
||||||
for _, t := range r.tasks {
|
|
||||||
task := t
|
|
||||||
r.wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer r.wg.Done()
|
|
||||||
defer r.recoverPanic(task)
|
|
||||||
|
|
||||||
interval := r.normalizeInterval(task.Interval())
|
|
||||||
|
|
||||||
// 可选:立即执行一次
|
|
||||||
if r.startImmediately {
|
|
||||||
r.runOnce(ctx, task)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 周期执行
|
|
||||||
for {
|
|
||||||
wait := r.applyJitter(interval)
|
|
||||||
if !r.wait(ctx, wait) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每轮读取最新的 interval,允许任务动态调整间隔
|
|
||||||
interval = r.normalizeInterval(task.Interval())
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
r.runOnce(ctx, task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait 等待所有任务退出
|
|
||||||
func (r *Runner) Wait() {
|
|
||||||
r.wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Runner) runOnce(ctx context.Context, task Task) {
|
|
||||||
if err := task.Run(ctx); err != nil && r.logger != nil {
|
|
||||||
r.logger.Warn("任务执行失败", zap.String("task", task.Name()), zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeInterval 确保间隔为正值
|
|
||||||
func (r *Runner) normalizeInterval(d time.Duration) time.Duration {
|
|
||||||
if d <= 0 {
|
|
||||||
return time.Minute
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyJitter 在基础间隔上添加最多 jitterPercent 的随机抖动
|
|
||||||
func (r *Runner) applyJitter(base time.Duration) time.Duration {
|
|
||||||
if r.jitterPercent <= 0 {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
maxJitter := time.Duration(float64(base) * r.jitterPercent)
|
|
||||||
if maxJitter <= 0 {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
return base + time.Duration(rand.Int63n(int64(maxJitter)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait 封装带 context 的 sleep
|
|
||||||
func (r *Runner) wait(ctx context.Context, d time.Duration) bool {
|
|
||||||
if d <= 0 {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
timer := time.NewTimer(d)
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return false
|
|
||||||
case <-timer.C:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recoverPanic 防止任务 panic 导致 goroutine 退出
|
|
||||||
func (r *Runner) recoverPanic(task Task) {
|
|
||||||
if rec := recover(); rec != nil && r.logger != nil {
|
|
||||||
r.logger.Error("任务发生panic",
|
|
||||||
zap.String("task", task.Name()),
|
|
||||||
zap.Any("panic", rec),
|
|
||||||
zap.ByteString("stack", debug.Stack()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package task
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mockTask struct {
|
|
||||||
name string
|
|
||||||
interval time.Duration
|
|
||||||
err error
|
|
||||||
runCount *atomic.Int32
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockTask) Name() string { return m.name }
|
|
||||||
func (m *mockTask) Interval() time.Duration { return m.interval }
|
|
||||||
func (m *mockTask) Run(ctx context.Context) error {
|
|
||||||
if m.runCount != nil {
|
|
||||||
m.runCount.Add(1)
|
|
||||||
}
|
|
||||||
return m.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunner_StartAndWait(t *testing.T) {
|
|
||||||
runCount := &atomic.Int32{}
|
|
||||||
task := &mockTask{name: "ok", interval: 20 * time.Millisecond, runCount: runCount}
|
|
||||||
runner := NewRunner(zap.NewNop(), task)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
runner.Start(ctx)
|
|
||||||
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
runner.Wait()
|
|
||||||
|
|
||||||
if runCount.Load() == 0 {
|
|
||||||
t.Fatalf("expected task to run at least once")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunner_RunErrorLogged(t *testing.T) {
|
|
||||||
runCount := &atomic.Int32{}
|
|
||||||
task := &mockTask{name: "err", interval: 10 * time.Millisecond, err: errors.New("boom"), runCount: runCount}
|
|
||||||
runner := NewRunner(zap.NewNop(), task)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
runner.Start(ctx)
|
|
||||||
time.Sleep(25 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
runner.Wait()
|
|
||||||
|
|
||||||
if runCount.Load() == 0 {
|
|
||||||
t.Fatalf("expected task to be attempted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package testutil
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"carrotskin/internal/model"
|
|
||||||
"carrotskin/pkg/database"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewTestDB 返回基于内存的 sqlite 数据库并完成模型迁移
|
|
||||||
func NewTestDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open sqlite memory db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.AutoMigrate(
|
|
||||||
&model.User{},
|
|
||||||
&model.UserPointLog{},
|
|
||||||
&model.UserLoginLog{},
|
|
||||||
&model.Profile{},
|
|
||||||
&model.Texture{},
|
|
||||||
&model.UserTextureFavorite{},
|
|
||||||
&model.TextureDownloadLog{},
|
|
||||||
&model.Client{},
|
|
||||||
&model.Yggdrasil{},
|
|
||||||
&model.SystemConfig{},
|
|
||||||
&model.AuditLog{},
|
|
||||||
&model.CasbinRule{},
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("failed to migrate models: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewNoopLogger 返回无输出 logger
|
|
||||||
func NewNoopLogger() *zap.Logger {
|
|
||||||
return zap.NewNop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTestCache 返回禁用 redis 的缓存管理器(用于单元测试)
|
|
||||||
func NewTestCache() *database.CacheManager {
|
|
||||||
return database.NewCacheManager(nil, database.CacheConfig{
|
|
||||||
Prefix: "test:",
|
|
||||||
Expiration: 1 * time.Minute,
|
|
||||||
Enabled: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package testutil
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestNewTestDB(t *testing.T) {
|
|
||||||
db := NewTestDB(t)
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("DB() err: %v", err)
|
|
||||||
}
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
|
||||||
t.Fatalf("ping err: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewTestCache(t *testing.T) {
|
|
||||||
cache := NewTestCache()
|
|
||||||
if cache.Policy.UserTTL == 0 {
|
|
||||||
t.Fatalf("expected defaults filled")
|
|
||||||
}
|
|
||||||
// disabled cache should not error on Set
|
|
||||||
if err := cache.Set(nil, "k", "v"); err != nil {
|
|
||||||
t.Fatalf("Set on disabled cache should be nil err, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -65,19 +65,6 @@ type ChangeEmailRequest struct {
|
|||||||
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"`
|
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateAvatarUploadURLRequest 生成头像上传URL请求
|
|
||||||
type GenerateAvatarUploadURLRequest struct {
|
|
||||||
FileName string `json:"file_name" binding:"required" example:"avatar.png"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAvatarUploadURLResponse 生成头像上传URL响应
|
|
||||||
type GenerateAvatarUploadURLResponse struct {
|
|
||||||
PostURL string `json:"post_url" example:"https://rustfs.example.com/avatars"`
|
|
||||||
FormData map[string]string `json:"form_data"`
|
|
||||||
AvatarURL string `json:"avatar_url" example:"https://rustfs.example.com/avatars/user_1/xxx.png"`
|
|
||||||
ExpiresIn int `json:"expires_in" example:"900"` // 秒
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateProfileRequest 创建档案请求
|
// CreateProfileRequest 创建档案请求
|
||||||
type CreateProfileRequest struct {
|
type CreateProfileRequest struct {
|
||||||
Name string `json:"name" binding:"required,min=1,max=16" example:"PlayerName"`
|
Name string `json:"name" binding:"required,min=1,max=16" example:"PlayerName"`
|
||||||
@@ -90,20 +77,6 @@ type UpdateTextureRequest struct {
|
|||||||
IsPublic *bool `json:"is_public" example:"true"`
|
IsPublic *bool `json:"is_public" example:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateTextureUploadURLRequest 生成材质上传URL请求
|
|
||||||
type GenerateTextureUploadURLRequest struct {
|
|
||||||
FileName string `json:"file_name" binding:"required" example:"skin.png"`
|
|
||||||
TextureType TextureType `json:"texture_type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateTextureUploadURLResponse 生成材质上传URL响应
|
|
||||||
type GenerateTextureUploadURLResponse struct {
|
|
||||||
PostURL string `json:"post_url" example:"https://rustfs.example.com/textures"`
|
|
||||||
FormData map[string]string `json:"form_data"`
|
|
||||||
TextureURL string `json:"texture_url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
|
|
||||||
ExpiresIn int `json:"expires_in" example:"900"` // 秒
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginResponse 登录响应
|
// LoginResponse 登录响应
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@@ -158,7 +131,6 @@ type ProfileInfo struct {
|
|||||||
Name string `json:"name" example:"PlayerName"`
|
Name string `json:"name" example:"PlayerName"`
|
||||||
SkinID *int64 `json:"skin_id,omitempty" example:"1"`
|
SkinID *int64 `json:"skin_id,omitempty" example:"1"`
|
||||||
CapeID *int64 `json:"cape_id,omitempty" example:"2"`
|
CapeID *int64 `json:"cape_id,omitempty" example:"2"`
|
||||||
IsActive bool `json:"is_active" example:"true"`
|
|
||||||
LastUsedAt *time.Time `json:"last_used_at,omitempty" example:"2025-10-01T12:00:00Z"`
|
LastUsedAt *time.Time `json:"last_used_at,omitempty" example:"2025-10-01T12:00:00Z"`
|
||||||
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
|
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
|
||||||
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
|
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
|
||||||
@@ -178,18 +150,6 @@ type UploadURLResponse struct {
|
|||||||
ExpiresIn int `json:"expires_in"`
|
ExpiresIn int `json:"expires_in"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTextureRequest 创建材质请求
|
|
||||||
type CreateTextureRequest struct {
|
|
||||||
Name string `json:"name" binding:"required,min=1,max=100" example:"My Cool Skin"`
|
|
||||||
Description string `json:"description" binding:"max=500" example:"A very cool skin"`
|
|
||||||
Type TextureType `json:"type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
|
|
||||||
URL string `json:"url" binding:"required,url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
|
|
||||||
Hash string `json:"hash" binding:"required,len=64" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
|
|
||||||
Size int `json:"size" binding:"required,min=1" example:"2048"`
|
|
||||||
IsPublic bool `json:"is_public" example:"true"`
|
|
||||||
IsSlim bool `json:"is_slim" example:"false"` // Alex模型(细臂)为true,Steve模型(粗臂)为false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchTextureRequest 搜索材质请求
|
// SearchTextureRequest 搜索材质请求
|
||||||
type SearchTextureRequest struct {
|
type SearchTextureRequest struct {
|
||||||
PaginationRequest
|
PaginationRequest
|
||||||
@@ -213,3 +173,12 @@ type SystemConfigResponse struct {
|
|||||||
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
|
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
|
||||||
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
|
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderResponse 材质渲染响应
|
||||||
|
type RenderResponse struct {
|
||||||
|
URL string `json:"url" example:"https://rustfs.example.com/renders/xxx.png"`
|
||||||
|
ContentType string `json:"content_type" example:"image/png"`
|
||||||
|
ETag string `json:"etag,omitempty" example:"abc123def456"`
|
||||||
|
Size int64 `json:"size" example:"2048"`
|
||||||
|
LastModified *time.Time `json:"last_modified,omitempty" example:"2025-10-01T12:00:00Z"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,3 +38,9 @@ func MustGetJWTService() *JWTService {
|
|||||||
}
|
}
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"carrotskin/pkg/redis"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TokenMetadata Token元数据(存储在Redis中)
|
|
||||||
type TokenMetadata struct {
|
|
||||||
UserID int64 `json:"user_id"`
|
|
||||||
ProfileID string `json:"profile_id"`
|
|
||||||
ClientUUID string `json:"client_uuid"`
|
|
||||||
ClientToken string `json:"client_token"`
|
|
||||||
Version int `json:"version"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenStoreRedis Redis Token存储实现
|
|
||||||
type TokenStoreRedis struct {
|
|
||||||
redis *redis.Client
|
|
||||||
logger *zap.Logger
|
|
||||||
keyPrefix string
|
|
||||||
defaultTTL time.Duration
|
|
||||||
staleTTL time.Duration
|
|
||||||
maxTokensPerUser int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTokenStoreRedis 创建Redis Token存储
|
|
||||||
func NewTokenStoreRedis(
|
|
||||||
redisClient *redis.Client,
|
|
||||||
logger *zap.Logger,
|
|
||||||
opts ...TokenStoreOption,
|
|
||||||
) *TokenStoreRedis {
|
|
||||||
options := &tokenStoreOptions{
|
|
||||||
keyPrefix: "token:",
|
|
||||||
defaultTTL: 24 * time.Hour,
|
|
||||||
staleTTL: 30 * 24 * time.Hour,
|
|
||||||
maxTokensPerUser: 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TokenStoreRedis{
|
|
||||||
redis: redisClient,
|
|
||||||
logger: logger,
|
|
||||||
keyPrefix: options.keyPrefix,
|
|
||||||
defaultTTL: options.defaultTTL,
|
|
||||||
staleTTL: options.staleTTL,
|
|
||||||
maxTokensPerUser: options.maxTokensPerUser,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tokenStoreOptions Token存储配置选项
|
|
||||||
type tokenStoreOptions struct {
|
|
||||||
keyPrefix string
|
|
||||||
defaultTTL time.Duration
|
|
||||||
staleTTL time.Duration
|
|
||||||
maxTokensPerUser int
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenStoreOption Token存储配置选项函数
|
|
||||||
type TokenStoreOption func(*tokenStoreOptions)
|
|
||||||
|
|
||||||
// WithKeyPrefix 设置Key前缀
|
|
||||||
func WithKeyPrefix(prefix string) TokenStoreOption {
|
|
||||||
return func(o *tokenStoreOptions) {
|
|
||||||
o.keyPrefix = prefix
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithDefaultTTL 设置默认TTL
|
|
||||||
func WithDefaultTTL(ttl time.Duration) TokenStoreOption {
|
|
||||||
return func(o *tokenStoreOptions) {
|
|
||||||
o.defaultTTL = ttl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithStaleTTL 设置过期但可用时间
|
|
||||||
func WithStaleTTL(ttl time.Duration) TokenStoreOption {
|
|
||||||
return func(o *tokenStoreOptions) {
|
|
||||||
o.staleTTL = ttl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithMaxTokensPerUser 设置每个用户的最大Token数
|
|
||||||
func WithMaxTokensPerUser(max int) TokenStoreOption {
|
|
||||||
return func(o *tokenStoreOptions) {
|
|
||||||
o.maxTokensPerUser = max
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store 存储Token
|
|
||||||
func (s *TokenStoreRedis) Store(ctx context.Context, accessToken string, metadata *TokenMetadata, ttl time.Duration) error {
|
|
||||||
if ttl <= 0 {
|
|
||||||
ttl = s.defaultTTL
|
|
||||||
}
|
|
||||||
|
|
||||||
// 序列化元数据
|
|
||||||
data, err := json.Marshal(metadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("序列化Token元数据失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存储Token
|
|
||||||
tokenKey := s.getTokenKey(accessToken)
|
|
||||||
if err := s.redis.Set(ctx, tokenKey, data, ttl); err != nil {
|
|
||||||
return fmt.Errorf("存储Token失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到用户Token集合
|
|
||||||
userTokensKey := s.getUserTokensKey(metadata.UserID)
|
|
||||||
if err := s.redis.SAdd(ctx, userTokensKey, accessToken); err != nil {
|
|
||||||
return fmt.Errorf("添加到用户Token集合失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理过期Token(后台执行)
|
|
||||||
go s.cleanupUserTokens(context.Background(), metadata.UserID)
|
|
||||||
|
|
||||||
s.logger.Debug("Token已存储",
|
|
||||||
zap.String("token", accessToken[:20]+"..."),
|
|
||||||
zap.Int64("userId", metadata.UserID),
|
|
||||||
zap.Duration("ttl", ttl),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve 获取Token元数据
|
|
||||||
func (s *TokenStoreRedis) Retrieve(ctx context.Context, accessToken string) (*TokenMetadata, error) {
|
|
||||||
tokenKey := s.getTokenKey(accessToken)
|
|
||||||
data, err := s.redis.Get(ctx, tokenKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取Token失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata TokenMetadata
|
|
||||||
if err := json.Unmarshal([]byte(data), &metadata); err != nil {
|
|
||||||
return nil, fmt.Errorf("解析Token元数据失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &metadata, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 删除Token
|
|
||||||
func (s *TokenStoreRedis) Delete(ctx context.Context, accessToken string) error {
|
|
||||||
tokenKey := s.getTokenKey(accessToken)
|
|
||||||
|
|
||||||
// 先获取Token元数据以获取UserID
|
|
||||||
metadata, err := s.Retrieve(ctx, accessToken)
|
|
||||||
if err != nil {
|
|
||||||
// Token可能已过期,忽略错误
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除Token
|
|
||||||
if err := s.redis.Del(ctx, tokenKey); err != nil {
|
|
||||||
return fmt.Errorf("删除Token失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从用户Token集合中移除
|
|
||||||
userTokensKey := s.getUserTokensKey(metadata.UserID)
|
|
||||||
if err := s.redis.SRem(ctx, userTokensKey, accessToken); err != nil {
|
|
||||||
return fmt.Errorf("从用户Token集合移除失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Debug("Token已删除",
|
|
||||||
zap.String("token", accessToken[:20]+"..."),
|
|
||||||
zap.Int64("userId", metadata.UserID),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteByUserID 删除用户的所有Token
|
|
||||||
func (s *TokenStoreRedis) DeleteByUserID(ctx context.Context, userID int64) error {
|
|
||||||
userTokensKey := s.getUserTokensKey(userID)
|
|
||||||
|
|
||||||
// 获取用户所有Token
|
|
||||||
tokens, err := s.redis.SMembers(ctx, userTokensKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("获取用户Token列表失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除所有Token
|
|
||||||
if len(tokens) > 0 {
|
|
||||||
tokenKeys := make([]string, len(tokens))
|
|
||||||
for i, token := range tokens {
|
|
||||||
tokenKeys[i] = s.getTokenKey(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.redis.Del(ctx, tokenKeys...); err != nil {
|
|
||||||
return fmt.Errorf("批量删除Token失败: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除用户Token集合
|
|
||||||
if err := s.redis.Del(ctx, userTokensKey); err != nil {
|
|
||||||
return fmt.Errorf("删除用户Token集合失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("用户所有Token已删除",
|
|
||||||
zap.Int64("userId", userID),
|
|
||||||
zap.Int("count", len(tokens)),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exists 检查Token是否存在
|
|
||||||
func (s *TokenStoreRedis) Exists(ctx context.Context, accessToken string) (bool, error) {
|
|
||||||
tokenKey := s.getTokenKey(accessToken)
|
|
||||||
count, err := s.redis.Exists(ctx, tokenKey)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("检查Token存在失败: %w", err)
|
|
||||||
}
|
|
||||||
return count > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTTL 获取Token的剩余TTL
|
|
||||||
func (s *TokenStoreRedis) GetTTL(ctx context.Context, accessToken string) (time.Duration, error) {
|
|
||||||
tokenKey := s.getTokenKey(accessToken)
|
|
||||||
return s.redis.TTL(ctx, tokenKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshTTL 刷新Token的TTL
|
|
||||||
func (s *TokenStoreRedis) RefreshTTL(ctx context.Context, accessToken string, ttl time.Duration) error {
|
|
||||||
if ttl <= 0 {
|
|
||||||
ttl = s.defaultTTL
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenKey := s.getTokenKey(accessToken)
|
|
||||||
if err := s.redis.Expire(ctx, tokenKey, ttl); err != nil {
|
|
||||||
return fmt.Errorf("刷新Token TTL失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCountByUser 获取用户的Token数量
|
|
||||||
func (s *TokenStoreRedis) GetCountByUser(ctx context.Context, userID int64) (int64, error) {
|
|
||||||
userTokensKey := s.getUserTokensKey(userID)
|
|
||||||
count, err := s.redis.SMembers(ctx, userTokensKey)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("获取用户Token数量失败: %w", err)
|
|
||||||
}
|
|
||||||
return int64(len(count)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupUserTokens 清理用户的过期Token(保留最新的N个)
|
|
||||||
func (s *TokenStoreRedis) cleanupUserTokens(ctx context.Context, userID int64) {
|
|
||||||
userTokensKey := s.getUserTokensKey(userID)
|
|
||||||
|
|
||||||
// 获取用户所有Token
|
|
||||||
tokens, err := s.redis.SMembers(ctx, userTokensKey)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("获取用户Token列表失败", zap.Error(err), zap.Int64("userId", userID))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理过期的Token(验证它们是否仍存在)
|
|
||||||
validTokens := make([]string, 0, len(tokens))
|
|
||||||
for _, token := range tokens {
|
|
||||||
tokenKey := s.getTokenKey(token)
|
|
||||||
exists, err := s.redis.Exists(ctx, tokenKey)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("检查Token存在失败", zap.Error(err), zap.String("token", token[:20]+"..."))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists > 0 {
|
|
||||||
validTokens = append(validTokens, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有变化,直接返回
|
|
||||||
if len(validTokens) == len(tokens) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新用户Token集合
|
|
||||||
if len(validTokens) == 0 {
|
|
||||||
s.redis.Del(ctx, userTokensKey)
|
|
||||||
} else {
|
|
||||||
// 重新设置集合
|
|
||||||
s.redis.Del(ctx, userTokensKey)
|
|
||||||
for _, token := range validTokens {
|
|
||||||
s.redis.SAdd(ctx, userTokensKey, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果超过限制,删除最旧的Token(这里简化处理,可以根据createdAt排序)
|
|
||||||
if len(validTokens) > s.maxTokensPerUser {
|
|
||||||
tokensToDelete := validTokens[s.maxTokensPerUser:]
|
|
||||||
for _, token := range tokensToDelete {
|
|
||||||
s.Delete(ctx, token)
|
|
||||||
}
|
|
||||||
s.logger.Info("清理用户多余Token",
|
|
||||||
zap.Int64("userId", userID),
|
|
||||||
zap.Int("deleted", len(tokensToDelete)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTokenKey 生成Token的Redis Key
|
|
||||||
func (s *TokenStoreRedis) getTokenKey(accessToken string) string {
|
|
||||||
return s.keyPrefix + accessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// getUserTokensKey 生成用户Token集合的Redis Key
|
|
||||||
func (s *TokenStoreRedis) getUserTokensKey(userID int64) string {
|
|
||||||
return fmt.Sprintf("user:%d:tokens", userID)
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ type Config struct {
|
|||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Casbin CasbinConfig `mapstructure:"casbin"`
|
Casbin CasbinConfig `mapstructure:"casbin"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
Upload UploadConfig `mapstructure:"upload"`
|
|
||||||
Email EmailConfig `mapstructure:"email"`
|
Email EmailConfig `mapstructure:"email"`
|
||||||
Security SecurityConfig `mapstructure:"security"`
|
Security SecurityConfig `mapstructure:"security"`
|
||||||
}
|
}
|
||||||
@@ -99,14 +98,6 @@ type LogConfig struct {
|
|||||||
Compress bool `mapstructure:"compress"`
|
Compress bool `mapstructure:"compress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadConfig 文件上传配置
|
|
||||||
type UploadConfig struct {
|
|
||||||
MaxSize int64 `mapstructure:"max_size"`
|
|
||||||
AllowedTypes []string `mapstructure:"allowed_types"`
|
|
||||||
TextureMaxSize int64 `mapstructure:"texture_max_size"`
|
|
||||||
AvatarMaxSize int64 `mapstructure:"avatar_max_size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EmailConfig 邮件配置
|
// EmailConfig 邮件配置
|
||||||
type EmailConfig struct {
|
type EmailConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
@@ -203,12 +194,6 @@ func setDefaults() {
|
|||||||
viper.SetDefault("log.max_age", 28)
|
viper.SetDefault("log.max_age", 28)
|
||||||
viper.SetDefault("log.compress", true)
|
viper.SetDefault("log.compress", true)
|
||||||
|
|
||||||
// 文件上传默认配置
|
|
||||||
viper.SetDefault("upload.max_size", 10485760)
|
|
||||||
viper.SetDefault("upload.texture_max_size", 2097152)
|
|
||||||
viper.SetDefault("upload.avatar_max_size", 1048576)
|
|
||||||
viper.SetDefault("upload.allowed_types", []string{"image/png", "image/jpeg"})
|
|
||||||
|
|
||||||
// 邮件默认配置
|
// 邮件默认配置
|
||||||
viper.SetDefault("email.enabled", false)
|
viper.SetDefault("email.enabled", false)
|
||||||
viper.SetDefault("email.smtp_port", 587)
|
viper.SetDefault("email.smtp_port", 587)
|
||||||
@@ -370,25 +355,6 @@ func overrideFromEnv(config *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理文件上传配置
|
|
||||||
if maxSize := os.Getenv("UPLOAD_MAX_SIZE"); maxSize != "" {
|
|
||||||
if val, err := strconv.ParseInt(maxSize, 10, 64); err == nil {
|
|
||||||
config.Upload.MaxSize = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if textureMaxSize := os.Getenv("UPLOAD_TEXTURE_MAX_SIZE"); textureMaxSize != "" {
|
|
||||||
if val, err := strconv.ParseInt(textureMaxSize, 10, 64); err == nil {
|
|
||||||
config.Upload.TextureMaxSize = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if avatarMaxSize := os.Getenv("UPLOAD_AVATAR_MAX_SIZE"); avatarMaxSize != "" {
|
|
||||||
if val, err := strconv.ParseInt(avatarMaxSize, 10, 64); err == nil {
|
|
||||||
config.Upload.AvatarMaxSize = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理邮件配置
|
// 处理邮件配置
|
||||||
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
|
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
|
||||||
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"
|
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 重置 viper,避免测试间干扰
|
|
||||||
func resetViper() {
|
|
||||||
viper.Reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoad_DefaultsAndBucketsOverride(t *testing.T) {
|
|
||||||
resetViper()
|
|
||||||
// 设置部分环境变量覆盖
|
|
||||||
_ = os.Setenv("RUSTFS_BUCKET_TEXTURES", "tex-bkt")
|
|
||||||
_ = os.Setenv("RUSTFS_BUCKET_AVATARS", "ava-bkt")
|
|
||||||
_ = os.Setenv("DATABASE_MAX_IDLE_CONNS", "20")
|
|
||||||
_ = os.Setenv("DATABASE_MAX_OPEN_CONNS", "50")
|
|
||||||
_ = os.Setenv("DATABASE_CONN_MAX_LIFETIME", "2h")
|
|
||||||
_ = os.Setenv("DATABASE_CONN_MAX_IDLE_TIME", "30m")
|
|
||||||
|
|
||||||
cfg, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认值检查
|
|
||||||
if cfg.Server.Port == "" || cfg.Database.Driver == "" || cfg.Redis.Host == "" {
|
|
||||||
t.Fatalf("expected defaults filled: %+v", cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 覆盖检查
|
|
||||||
if cfg.RustFS.Buckets["textures"] != "tex-bkt" || cfg.RustFS.Buckets["avatars"] != "ava-bkt" {
|
|
||||||
t.Fatalf("buckets override failed: %+v", cfg.RustFS.Buckets)
|
|
||||||
}
|
|
||||||
if cfg.Database.MaxIdleConns != 20 || cfg.Database.MaxOpenConns != 50 {
|
|
||||||
t.Fatalf("db pool override failed: %+v", cfg.Database)
|
|
||||||
}
|
|
||||||
if cfg.Database.ConnMaxLifetime.String() != "2h0m0s" || cfg.Database.ConnMaxIdleTime.String() != "30m0s" {
|
|
||||||
t.Fatalf("db duration override failed: %v %v", cfg.Database.ConnMaxLifetime, cfg.Database.ConnMaxIdleTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -63,3 +63,9 @@ func MustGetRustFSConfig() *RustFSConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,24 +14,12 @@ type CacheConfig struct {
|
|||||||
Prefix string // 缓存键前缀
|
Prefix string // 缓存键前缀
|
||||||
Expiration time.Duration // 过期时间
|
Expiration time.Duration // 过期时间
|
||||||
Enabled bool // 是否启用缓存
|
Enabled bool // 是否启用缓存
|
||||||
Policy CachePolicy // 缓存策略(可选,不配置则回落到 Expiration)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CachePolicy 缓存策略,用于为不同实体设置默认 TTL
|
|
||||||
type CachePolicy struct {
|
|
||||||
UserTTL time.Duration
|
|
||||||
UserEmailTTL time.Duration
|
|
||||||
ProfileTTL time.Duration
|
|
||||||
ProfileListTTL time.Duration
|
|
||||||
TextureTTL time.Duration
|
|
||||||
TextureListTTL time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheManager 缓存管理器
|
// CacheManager 缓存管理器
|
||||||
type CacheManager struct {
|
type CacheManager struct {
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
config CacheConfig
|
config CacheConfig
|
||||||
Policy CachePolicy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCacheManager 创建缓存管理器
|
// NewCacheManager 创建缓存管理器
|
||||||
@@ -43,33 +31,9 @@ func NewCacheManager(redisClient *redis.Client, config CacheConfig) *CacheManage
|
|||||||
config.Expiration = 5 * time.Minute
|
config.Expiration = 5 * time.Minute
|
||||||
}
|
}
|
||||||
|
|
||||||
// 填充默认策略(未配置时退回全局过期时间)
|
|
||||||
applyPolicyDefaults := func(p *CachePolicy) {
|
|
||||||
if p.UserTTL == 0 {
|
|
||||||
p.UserTTL = config.Expiration
|
|
||||||
}
|
|
||||||
if p.UserEmailTTL == 0 {
|
|
||||||
p.UserEmailTTL = config.Expiration
|
|
||||||
}
|
|
||||||
if p.ProfileTTL == 0 {
|
|
||||||
p.ProfileTTL = config.Expiration
|
|
||||||
}
|
|
||||||
if p.ProfileListTTL == 0 {
|
|
||||||
p.ProfileListTTL = config.Expiration
|
|
||||||
}
|
|
||||||
if p.TextureTTL == 0 {
|
|
||||||
p.TextureTTL = config.Expiration
|
|
||||||
}
|
|
||||||
if p.TextureListTTL == 0 {
|
|
||||||
p.TextureListTTL = config.Expiration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyPolicyDefaults(&config.Policy)
|
|
||||||
|
|
||||||
return &CacheManager{
|
return &CacheManager{
|
||||||
redis: redisClient,
|
redis: redisClient,
|
||||||
config: config,
|
config: config,
|
||||||
Policy: config.Policy,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,14 +56,6 @@ func (cm *CacheManager) Get(ctx context.Context, key string, dest interface{}) e
|
|||||||
return json.Unmarshal(data, dest)
|
return json.Unmarshal(data, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TryGet 获取缓存,命中时返回 true,不视为错误
|
|
||||||
func (cm *CacheManager) TryGet(ctx context.Context, key string, dest interface{}) (bool, error) {
|
|
||||||
if err := cm.Get(ctx, key, dest); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set 设置缓存
|
// Set 设置缓存
|
||||||
func (cm *CacheManager) Set(ctx context.Context, key string, value interface{}, expiration ...time.Duration) error {
|
func (cm *CacheManager) Set(ctx context.Context, key string, value interface{}, expiration ...time.Duration) error {
|
||||||
if !cm.config.Enabled || cm.redis == nil {
|
if !cm.config.Enabled || cm.redis == nil {
|
||||||
@@ -119,13 +75,6 @@ func (cm *CacheManager) Set(ctx context.Context, key string, value interface{},
|
|||||||
return cm.redis.Set(ctx, cm.buildKey(key), data, exp)
|
return cm.redis.Set(ctx, cm.buildKey(key), data, exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAsync 异步设置缓存,避免在主请求链路阻塞
|
|
||||||
func (cm *CacheManager) SetAsync(ctx context.Context, key string, value interface{}, expiration ...time.Duration) {
|
|
||||||
go func() {
|
|
||||||
_ = cm.Set(ctx, key, value, expiration...)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 删除缓存
|
// Delete 删除缓存
|
||||||
func (cm *CacheManager) Delete(ctx context.Context, keys ...string) error {
|
func (cm *CacheManager) Delete(ctx context.Context, keys ...string) error {
|
||||||
if !cm.config.Enabled || cm.redis == nil {
|
if !cm.config.Enabled || cm.redis == nil {
|
||||||
@@ -238,7 +187,11 @@ func Cached[T any](
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 设置缓存(异步,不阻塞)
|
// 设置缓存(异步,不阻塞)
|
||||||
cache.SetAsync(context.Background(), key, data, expiration...)
|
go func() {
|
||||||
|
cacheCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = cache.Set(cacheCtx, key, data, expiration...)
|
||||||
|
}()
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
@@ -264,7 +217,11 @@ func CachedList[T any](
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 设置缓存(异步,不阻塞)
|
// 设置缓存(异步,不阻塞)
|
||||||
cache.SetAsync(context.Background(), key, data, expiration...)
|
go func() {
|
||||||
|
cacheCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = cache.Set(cacheCtx, key, data, expiration...)
|
||||||
|
}()
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
@@ -349,9 +306,9 @@ func (b *CacheKeyBuilder) TextureList(userID int64, page int) string {
|
|||||||
return fmt.Sprintf("%stexture:user:%d:page:%d", b.prefix, userID, page)
|
return fmt.Sprintf("%stexture:user:%d:page:%d", b.prefix, userID, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextureListPattern 构建材质列表缓存键模式(用于批量失效)
|
// TextureRender 构建材质渲染缓存键
|
||||||
func (b *CacheKeyBuilder) TextureListPattern(userID int64) string {
|
func (b *CacheKeyBuilder) TextureRender(textureID int64, renderType string, size int) string {
|
||||||
return fmt.Sprintf("%stexture:user:%d:*", b.prefix, userID)
|
return fmt.Sprintf("%stexture:render:%d:%s:%d", b.prefix, textureID, renderType, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token 构建令牌缓存键
|
// Token 构建令牌缓存键
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
pkgRedis "carrotskin/pkg/redis"
|
|
||||||
|
|
||||||
miniredis "github.com/alicebob/miniredis/v2"
|
|
||||||
goRedis "github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newCacheWithMiniRedis(t *testing.T) (*CacheManager, func()) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mr, err := miniredis.Run()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to start miniredis: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rdb := goRedis.NewClient(&goRedis.Options{
|
|
||||||
Addr: mr.Addr(),
|
|
||||||
})
|
|
||||||
client := &pkgRedis.Client{Client: rdb}
|
|
||||||
|
|
||||||
cache := NewCacheManager(client, CacheConfig{
|
|
||||||
Prefix: "t:",
|
|
||||||
Expiration: time.Minute,
|
|
||||||
Enabled: true,
|
|
||||||
Policy: CachePolicy{
|
|
||||||
UserTTL: 2 * time.Minute,
|
|
||||||
UserEmailTTL: 3 * time.Minute,
|
|
||||||
ProfileTTL: 2 * time.Minute,
|
|
||||||
ProfileListTTL: 90 * time.Second,
|
|
||||||
TextureTTL: 2 * time.Minute,
|
|
||||||
TextureListTTL: 45 * time.Second,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_ = rdb.Close()
|
|
||||||
mr.Close()
|
|
||||||
}
|
|
||||||
return cache, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheManager_GetSet_TryGet(t *testing.T) {
|
|
||||||
cache, cleanup := newCacheWithMiniRedis(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
type User struct {
|
|
||||||
ID int
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
u := User{ID: 1, Name: "alice"}
|
|
||||||
if err := cache.Set(ctx, "user:1", u, 10*time.Second); err != nil {
|
|
||||||
t.Fatalf("Set err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got User
|
|
||||||
if err := cache.Get(ctx, "user:1", &got); err != nil {
|
|
||||||
t.Fatalf("Get err: %v", err)
|
|
||||||
}
|
|
||||||
if got != u {
|
|
||||||
t.Fatalf("unexpected value: %+v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got2 User
|
|
||||||
ok, err := cache.TryGet(ctx, "user:1", &got2)
|
|
||||||
if err != nil || !ok {
|
|
||||||
t.Fatalf("TryGet failed, ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
if got2 != u {
|
|
||||||
t.Fatalf("unexpected TryGet: %+v", got2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheManager_DeletePattern(t *testing.T) {
|
|
||||||
cache, cleanup := newCacheWithMiniRedis(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
_ = cache.Set(ctx, "user:1", "a", 0)
|
|
||||||
_ = cache.Set(ctx, "user:2", "b", 0)
|
|
||||||
_ = cache.Set(ctx, "profile:1", "c", 0)
|
|
||||||
|
|
||||||
// 删除 user:* 键
|
|
||||||
if err := cache.DeletePattern(ctx, "user:*"); err != nil {
|
|
||||||
t.Fatalf("DeletePattern err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var v string
|
|
||||||
ok, _ := cache.TryGet(ctx, "user:1", &v)
|
|
||||||
if ok {
|
|
||||||
t.Fatalf("expected user:1 deleted")
|
|
||||||
}
|
|
||||||
ok, _ = cache.TryGet(ctx, "user:2", &v)
|
|
||||||
if ok {
|
|
||||||
t.Fatalf("expected user:2 deleted")
|
|
||||||
}
|
|
||||||
ok, _ = cache.TryGet(ctx, "profile:1", &v)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected profile:1 kept")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCachedAndCachedList(t *testing.T) {
|
|
||||||
cache, cleanup := newCacheWithMiniRedis(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
callCount := 0
|
|
||||||
result, err := Cached(ctx, cache, "key1", func() (*string, error) {
|
|
||||||
callCount++
|
|
||||||
val := "hello"
|
|
||||||
return &val, nil
|
|
||||||
}, cache.Policy.UserTTL)
|
|
||||||
if err != nil || *result != "hello" || callCount != 1 {
|
|
||||||
t.Fatalf("Cached first call failed")
|
|
||||||
}
|
|
||||||
// 等待缓存写入完成
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
var tmp string
|
|
||||||
if ok, _ := cache.TryGet(ctx, "key1", &tmp); ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第二次应命中缓存
|
|
||||||
_, err = Cached(ctx, cache, "key1", func() (*string, error) {
|
|
||||||
callCount++
|
|
||||||
val := "world"
|
|
||||||
return &val, nil
|
|
||||||
}, cache.Policy.UserTTL)
|
|
||||||
if err != nil || callCount != 1 {
|
|
||||||
t.Fatalf("Cached should hit cache, callCount=%d err=%v", callCount, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
listCall := 0
|
|
||||||
_, err = CachedList(ctx, cache, "list", func() ([]string, error) {
|
|
||||||
listCall++
|
|
||||||
return []string{"a", "b"}, nil
|
|
||||||
}, cache.Policy.ProfileListTTL)
|
|
||||||
if err != nil || listCall != 1 {
|
|
||||||
t.Fatalf("CachedList first call failed")
|
|
||||||
}
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
var tmp []string
|
|
||||||
if ok, _ := cache.TryGet(ctx, "list", &tmp); ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
_, err = CachedList(ctx, cache, "list", func() ([]string, error) {
|
|
||||||
listCall++
|
|
||||||
return []string{"c"}, nil
|
|
||||||
}, cache.Policy.ProfileListTTL)
|
|
||||||
if err != nil || listCall != 1 {
|
|
||||||
t.Fatalf("CachedList should hit cache, calls=%d err=%v", listCall, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIncrementWithExpire(t *testing.T) {
|
|
||||||
cache, cleanup := newCacheWithMiniRedis(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
val, err := cache.IncrementWithExpire(ctx, "counter", time.Second)
|
|
||||||
if err != nil || val != 1 {
|
|
||||||
t.Fatalf("first increment failed, val=%d err=%v", val, err)
|
|
||||||
}
|
|
||||||
val, err = cache.IncrementWithExpire(ctx, "counter", time.Second)
|
|
||||||
if err != nil || val != 2 {
|
|
||||||
t.Fatalf("second increment failed, val=%d err=%v", val, err)
|
|
||||||
}
|
|
||||||
ttl, err := cache.TTL(ctx, "counter")
|
|
||||||
if err != nil || ttl <= 0 {
|
|
||||||
t.Fatalf("TTL not set: ttl=%v err=%v", ttl, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,6 +75,7 @@ func AutoMigrate(logger *zap.Logger) error {
|
|||||||
&model.TextureDownloadLog{},
|
&model.TextureDownloadLog{},
|
||||||
|
|
||||||
// 认证相关表
|
// 认证相关表
|
||||||
|
&model.Token{},
|
||||||
&model.Client{}, // Client表用于管理Token版本
|
&model.Client{}, // Client表用于管理Token版本
|
||||||
|
|
||||||
// Yggdrasil相关表(在User之后创建,因为它引用User)
|
// Yggdrasil相关表(在User之后创建,因为它引用User)
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/zap/zaptest"
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 使用内存 sqlite 验证 AutoMigrate 关键路径,无需真实 Postgres
|
|
||||||
func TestAutoMigrate_WithSQLite(t *testing.T) {
|
|
||||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open sqlite err: %v", err)
|
|
||||||
}
|
|
||||||
dbInstance = db
|
|
||||||
defer func() { dbInstance = nil }()
|
|
||||||
|
|
||||||
logger := zaptest.NewLogger(t)
|
|
||||||
if err := AutoMigrate(logger); err != nil {
|
|
||||||
t.Fatalf("AutoMigrate sqlite err: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
// TestGetDB_NotInitialized 测试未初始化时获取数据库实例
|
// TestGetDB_NotInitialized 测试未初始化时获取数据库实例
|
||||||
func TestGetDB_NotInitialized(t *testing.T) {
|
func TestGetDB_NotInitialized(t *testing.T) {
|
||||||
dbInstance = nil
|
|
||||||
_, err := GetDB()
|
_, err := GetDB()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("未初始化时应该返回错误")
|
t.Error("未初始化时应该返回错误")
|
||||||
@@ -23,7 +22,6 @@ func TestGetDB_NotInitialized(t *testing.T) {
|
|||||||
|
|
||||||
// TestMustGetDB_Panic 测试MustGetDB在未初始化时panic
|
// TestMustGetDB_Panic 测试MustGetDB在未初始化时panic
|
||||||
func TestMustGetDB_Panic(t *testing.T) {
|
func TestMustGetDB_Panic(t *testing.T) {
|
||||||
dbInstance = nil
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r == nil {
|
if r := recover(); r == nil {
|
||||||
t.Error("MustGetDB 应该在未初始化时panic")
|
t.Error("MustGetDB 应该在未初始化时panic")
|
||||||
@@ -35,7 +33,6 @@ func TestMustGetDB_Panic(t *testing.T) {
|
|||||||
|
|
||||||
// TestInit_Database 测试数据库初始化逻辑
|
// TestInit_Database 测试数据库初始化逻辑
|
||||||
func TestInit_Database(t *testing.T) {
|
func TestInit_Database(t *testing.T) {
|
||||||
dbInstance = nil
|
|
||||||
cfg := config.DatabaseConfig{
|
cfg := config.DatabaseConfig{
|
||||||
Driver: "postgres",
|
Driver: "postgres",
|
||||||
Host: "localhost",
|
Host: "localhost",
|
||||||
@@ -56,7 +53,7 @@ func TestInit_Database(t *testing.T) {
|
|||||||
// 注意:实际连接可能失败,这是可以接受的
|
// 注意:实际连接可能失败,这是可以接受的
|
||||||
err := Init(cfg, logger)
|
err := Init(cfg, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("数据库未运行,跳过连接测试: %v", err)
|
t.Logf("Init() 返回错误(可能正常,如果数据库未运行): %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,3 +82,4 @@ func TestClose_NotInitialized(t *testing.T) {
|
|||||||
t.Errorf("Close() 在未初始化时应该返回nil,实际返回: %v", err)
|
t.Errorf("Close() 在未初始化时应该返回nil,实际返回: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,3 +99,9 @@ func GetDSN(cfg config.DatabaseConfig) string {
|
|||||||
cfg.Timezone,
|
cfg.Timezone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package email
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"carrotskin/pkg/config"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func resetEmailOnce() {
|
|
||||||
serviceInstance = nil
|
|
||||||
once = sync.Once{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmailManager_Disabled(t *testing.T) {
|
|
||||||
resetEmailOnce()
|
|
||||||
cfg := config.EmailConfig{Enabled: false}
|
|
||||||
if err := Init(cfg, zap.NewNop()); err != nil {
|
|
||||||
t.Fatalf("Init disabled err: %v", err)
|
|
||||||
}
|
|
||||||
svc := MustGetService()
|
|
||||||
if err := svc.SendVerificationCode("to@test.com", "123456", "email_verification"); err == nil {
|
|
||||||
t.Fatalf("expected error when disabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) {
|
|
||||||
resetEmailOnce()
|
|
||||||
cfg := config.EmailConfig{
|
|
||||||
Enabled: true,
|
|
||||||
SMTPHost: "127.0.0.1",
|
|
||||||
SMTPPort: 1, // invalid/closed port to trigger error quickly
|
|
||||||
Username: "user",
|
|
||||||
Password: "pwd",
|
|
||||||
FromName: "name",
|
|
||||||
}
|
|
||||||
_ = Init(cfg, zap.NewNop())
|
|
||||||
svc := MustGetService()
|
|
||||||
if err := svc.SendVerificationCode("to@test.com", "123456", "reset_password"); err == nil {
|
|
||||||
t.Fatalf("expected send error with invalid smtp")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmailManager_SubjectAndBody(t *testing.T) {
|
|
||||||
svc := &Service{cfg: config.EmailConfig{FromName: "name", Username: "user"}, logger: zap.NewNop()}
|
|
||||||
if subj := svc.getSubject("email_verification"); subj == "" {
|
|
||||||
t.Fatalf("subject empty")
|
|
||||||
}
|
|
||||||
body := svc.getBody("123456", "change_email")
|
|
||||||
if !strings.Contains(body, "123456") || !strings.Contains(body, "更换邮箱") {
|
|
||||||
t.Fatalf("body content mismatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,3 +46,9 @@ func MustGetService() *Service {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,13 @@ package email
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/pkg/config"
|
"carrotskin/pkg/config"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/zap/zaptest"
|
"go.uber.org/zap/zaptest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func resetEmail() {
|
|
||||||
serviceInstance = nil
|
|
||||||
once = sync.Once{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetService_NotInitialized 测试未初始化时获取邮件服务
|
// TestGetService_NotInitialized 测试未初始化时获取邮件服务
|
||||||
func TestGetService_NotInitialized(t *testing.T) {
|
func TestGetService_NotInitialized(t *testing.T) {
|
||||||
resetEmail()
|
|
||||||
_, err := GetService()
|
_, err := GetService()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("未初始化时应该返回错误")
|
t.Error("未初始化时应该返回错误")
|
||||||
@@ -29,7 +22,6 @@ func TestGetService_NotInitialized(t *testing.T) {
|
|||||||
|
|
||||||
// TestMustGetService_Panic 测试MustGetService在未初始化时panic
|
// TestMustGetService_Panic 测试MustGetService在未初始化时panic
|
||||||
func TestMustGetService_Panic(t *testing.T) {
|
func TestMustGetService_Panic(t *testing.T) {
|
||||||
resetEmail()
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r == nil {
|
if r := recover(); r == nil {
|
||||||
t.Error("MustGetService 应该在未初始化时panic")
|
t.Error("MustGetService 应该在未初始化时panic")
|
||||||
@@ -41,7 +33,6 @@ 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",
|
||||||
@@ -67,3 +58,4 @@ func TestInit_Email(t *testing.T) {
|
|||||||
t.Error("GetService() 返回的服务不应为nil")
|
t.Error("GetService() 返回的服务不应为nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,3 +49,9 @@ func MustGetLogger() *zap.Logger {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,8 @@ package redis
|
|||||||
import (
|
import (
|
||||||
"carrotskin/pkg/config"
|
"carrotskin/pkg/config"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/alicebob/miniredis/v2"
|
|
||||||
redis9 "github.com/redis/go-redis/v9"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,69 +15,19 @@ var (
|
|||||||
once sync.Once
|
once sync.Once
|
||||||
// initError 初始化错误
|
// initError 初始化错误
|
||||||
initError error
|
initError error
|
||||||
// miniredisInstance 用于测试/开发环境
|
|
||||||
miniredisInstance *miniredis.Miniredis
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init 初始化Redis客户端(线程安全,只会执行一次)
|
// Init 初始化Redis客户端(线程安全,只会执行一次)
|
||||||
// 如果Redis连接失败且环境为测试/开发,则回退到miniredis
|
|
||||||
func Init(cfg config.RedisConfig, logger *zap.Logger) error {
|
func Init(cfg config.RedisConfig, logger *zap.Logger) error {
|
||||||
var err error
|
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
// 尝试连接真实Redis
|
clientInstance, initError = New(cfg, logger)
|
||||||
clientInstance, err = New(cfg, logger)
|
if initError != nil {
|
||||||
if err != nil {
|
|
||||||
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 {
|
||||||
@@ -98,21 +45,13 @@ func MustGetClient() *Client {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close 关闭Redis连接(包括miniredis如果使用了)
|
|
||||||
func Close() error {
|
|
||||||
var err error
|
|
||||||
if miniredisInstance != nil {
|
|
||||||
miniredisInstance.Close()
|
|
||||||
miniredisInstance = nil
|
|
||||||
}
|
|
||||||
if clientInstance != nil {
|
|
||||||
err = clientInstance.Close()
|
|
||||||
clientInstance = nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsUsingMiniRedis 检查是否使用了miniredis
|
|
||||||
func IsUsingMiniRedis() bool {
|
|
||||||
return miniredisInstance != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,9 @@ func MustGetClient() *StorageClient {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -173,18 +173,34 @@ func (s *StorageClient) GetObject(ctx context.Context, bucketName, objectName st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseFileURL 从文件URL中解析出bucket和objectName
|
// ParseFileURL 从文件URL中解析出bucket和objectName
|
||||||
// URL格式: {publicURL}/{bucket}/{objectName}
|
// URL格式: {publicURL}/{bucket}/{objectName}[?query],自动忽略查询参数
|
||||||
func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) {
|
func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) {
|
||||||
// 移除 publicURL 前缀
|
u, err := url.Parse(fileURL)
|
||||||
if !strings.HasPrefix(fileURL, s.publicURL) {
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("URL解析失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验前缀(协议+主机+端口)
|
||||||
|
public, err := url.Parse(s.publicURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("publicURL解析失败: %w", err)
|
||||||
|
}
|
||||||
|
if u.Scheme != public.Scheme || u.Host != public.Host {
|
||||||
return "", "", fmt.Errorf("URL格式不正确,必须以 %s 开头", s.publicURL)
|
return "", "", fmt.Errorf("URL格式不正确,必须以 %s 开头", s.publicURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除 publicURL 前缀和开头的 /
|
// 去掉前缀与开头的斜杠,仅使用路径部分,不包含 query
|
||||||
path := strings.TrimPrefix(fileURL, s.publicURL)
|
path := strings.TrimPrefix(u.Path, "/")
|
||||||
path = strings.TrimPrefix(path, "/")
|
|
||||||
|
|
||||||
// 解析路径
|
// 如果 publicURL 自带路径前缀,移除该前缀
|
||||||
|
pubPath := strings.TrimPrefix(public.Path, "/")
|
||||||
|
if pubPath != "" {
|
||||||
|
if !strings.HasPrefix(path, pubPath) {
|
||||||
|
return "", "", fmt.Errorf("URL格式不正确,缺少前缀 %s", public.Path)
|
||||||
|
}
|
||||||
|
path = strings.TrimPrefix(path, pubPath)
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
}
|
||||||
parts := strings.SplitN(path, "/", 2)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return "", "", fmt.Errorf("URL格式不正确,无法解析bucket和objectName")
|
return "", "", fmt.Errorf("URL格式不正确,无法解析bucket和objectName")
|
||||||
@@ -194,8 +210,7 @@ func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string,
|
|||||||
objectName = parts[1]
|
objectName = parts[1]
|
||||||
|
|
||||||
// URL解码 objectName
|
// URL解码 objectName
|
||||||
decoded, err := url.PathUnescape(objectName)
|
if decoded, decErr := url.PathUnescape(objectName); decErr == nil {
|
||||||
if err == nil {
|
|
||||||
objectName = decoded
|
objectName = decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"carrotskin/pkg/config"
|
|
||||||
|
|
||||||
"github.com/minio/minio-go/v7"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 使用 nil client 仅测试纯函数和错误分支
|
|
||||||
func TestStorage_GetBucketAndBuildURL(t *testing.T) {
|
|
||||||
s := &StorageClient{
|
|
||||||
client: (*minio.Client)(nil),
|
|
||||||
buckets: map[string]string{"textures": "tex-bkt"},
|
|
||||||
publicURL: "http://localhost:9000",
|
|
||||||
}
|
|
||||||
|
|
||||||
if b, err := s.GetBucket("textures"); err != nil || b != "tex-bkt" {
|
|
||||||
t.Fatalf("GetBucket mismatch: %v %s", err, b)
|
|
||||||
}
|
|
||||||
if _, err := s.GetBucket("missing"); err == nil {
|
|
||||||
t.Fatalf("expected error for missing bucket")
|
|
||||||
}
|
|
||||||
|
|
||||||
if url := s.BuildFileURL("tex-bkt", "obj"); url != "http://localhost:9000/tex-bkt/obj" {
|
|
||||||
t.Fatalf("BuildFileURL mismatch: %s", url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewStorage_SkipConnectWhenNoCreds(t *testing.T) {
|
|
||||||
// 当 AccessKey/Secret 为空时跳过 ListBuckets 测试,避免真实依赖
|
|
||||||
cfg := config.RustFSConfig{
|
|
||||||
Endpoint: "127.0.0.1:9000",
|
|
||||||
Buckets: map[string]string{"avatars": "ava", "textures": "tex"},
|
|
||||||
UseSSL: false,
|
|
||||||
}
|
|
||||||
if _, err := NewStorage(cfg); err != nil {
|
|
||||||
t.Fatalf("NewStorage should not error when creds empty: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPresignedHelpers_WithNilClient(t *testing.T) {
|
|
||||||
s := &StorageClient{
|
|
||||||
client: (*minio.Client)(nil),
|
|
||||||
buckets: map[string]string{"textures": "tex-bkt"},
|
|
||||||
publicURL: "http://localhost:9000",
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// 预期会panic(nil client),用recover捕获
|
|
||||||
func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r == nil {
|
|
||||||
t.Fatalf("GeneratePresignedURL expected panic with nil client")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
_, _ = s.GeneratePresignedURL(ctx, "tex-bkt", "obj", time.Minute)
|
|
||||||
}()
|
|
||||||
func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r == nil {
|
|
||||||
t.Fatalf("GeneratePresignedPostURL expected panic with nil client")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
_, _ = s.GeneratePresignedPostURL(ctx, "tex-bkt", "obj", 0, 10, time.Minute)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
42
run.bat
42
run.bat
@@ -1,42 +0,0 @@
|
|||||||
@echo off
|
|
||||||
chcp 65001 >nul
|
|
||||||
echo ================================
|
|
||||||
echo CarrotSkin Backend Server
|
|
||||||
echo ================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo [1/3] Checking swag tool...
|
|
||||||
where swag >nul 2>nul
|
|
||||||
if %ERRORLEVEL% NEQ 0 (
|
|
||||||
echo [WARN] swag tool not found, installing...
|
|
||||||
go install github.com/swaggo/swag/cmd/swag@latest
|
|
||||||
if %ERRORLEVEL% NEQ 0 (
|
|
||||||
echo [ERROR] Failed to install swag
|
|
||||||
echo Please install manually: go install github.com/swaggo/swag/cmd/swag@latest
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo [OK] swag tool installed
|
|
||||||
) else (
|
|
||||||
echo [OK] swag tool found
|
|
||||||
)
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo [2/3] Generating Swagger documentation...
|
|
||||||
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
|
|
||||||
if %ERRORLEVEL% NEQ 0 (
|
|
||||||
echo [ERROR] Failed to generate Swagger docs
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo [OK] Swagger docs generated
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo [3/3] Starting server...
|
|
||||||
echo Server: http://localhost:8080
|
|
||||||
echo Swagger: http://localhost:8080/swagger/index.html
|
|
||||||
echo Health: http://localhost:8080/health
|
|
||||||
echo.
|
|
||||||
echo Press Ctrl+C to stop server
|
|
||||||
echo.
|
|
||||||
go run cmd/server/main.go
|
|
||||||
36
run.sh
36
run.sh
@@ -1,36 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "================================"
|
|
||||||
echo " CarrotSkin Backend Server"
|
|
||||||
echo "================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "[1/3] 检查swag工具..."
|
|
||||||
if ! command -v swag &> /dev/null; then
|
|
||||||
echo "[警告] swag工具未安装,正在安装..."
|
|
||||||
go install github.com/swaggo/swag/cmd/swag@latest
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "[错误] swag安装失败,请手动安装: go install github.com/swaggo/swag/cmd/swag@latest"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[成功] swag工具安装完成"
|
|
||||||
else
|
|
||||||
echo "[成功] swag工具已安装"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "[2/3] 生成Swagger API文档..."
|
|
||||||
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "[错误] Swagger文档生成失败"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[成功] Swagger文档生成完成"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "[3/3] 启动服务器..."
|
|
||||||
echo "服务地址: http://localhost:8080"
|
|
||||||
echo "Swagger文档: http://localhost:8080/swagger/index.html"
|
|
||||||
echo "按 Ctrl+C 停止服务"
|
|
||||||
echo ""
|
|
||||||
go run cmd/server/main.go
|
|
||||||
@@ -10,9 +10,12 @@ REQUIRED_VARS=(
|
|||||||
"DATABASE_USERNAME"
|
"DATABASE_USERNAME"
|
||||||
"DATABASE_PASSWORD"
|
"DATABASE_PASSWORD"
|
||||||
"DATABASE_NAME"
|
"DATABASE_NAME"
|
||||||
|
"REDIS_HOST"
|
||||||
"RUSTFS_ENDPOINT"
|
"RUSTFS_ENDPOINT"
|
||||||
"RUSTFS_ACCESS_KEY"
|
"RUSTFS_ACCESS_KEY"
|
||||||
"RUSTFS_SECRET_KEY"
|
"RUSTFS_SECRET_KEY"
|
||||||
|
"RUSTFS_BUCKET_TEXTURES"
|
||||||
|
"RUSTFS_BUCKET_AVATARS"
|
||||||
"JWT_SECRET"
|
"JWT_SECRET"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +29,9 @@ fi
|
|||||||
echo "✅ .env 文件存在"
|
echo "✅ .env 文件存在"
|
||||||
|
|
||||||
# 加载.env文件
|
# 加载.env文件
|
||||||
|
set -a
|
||||||
source .env 2>/dev/null
|
source .env 2>/dev/null
|
||||||
|
set +a
|
||||||
|
|
||||||
# 检查必需的环境变量
|
# 检查必需的环境变量
|
||||||
missing_vars=()
|
missing_vars=()
|
||||||
@@ -51,8 +56,10 @@ echo "✅ 所有必需的环境变量都已设置"
|
|||||||
# 检查关键配置的合理性
|
# 检查关键配置的合理性
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 当前配置概览:"
|
echo "📋 当前配置概览:"
|
||||||
echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:$DATABASE_PORT/$DATABASE_NAME"
|
echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:${DATABASE_PORT:-5432}/$DATABASE_NAME"
|
||||||
|
echo " Redis: $REDIS_HOST:${REDIS_PORT:-6379}"
|
||||||
echo " RustFS: $RUSTFS_ENDPOINT"
|
echo " RustFS: $RUSTFS_ENDPOINT"
|
||||||
|
echo " 存储桶: $RUSTFS_BUCKET_TEXTURES, $RUSTFS_BUCKET_AVATARS"
|
||||||
echo " JWT密钥长度: ${#JWT_SECRET} 字符"
|
echo " JWT密钥长度: ${#JWT_SECRET} 字符"
|
||||||
|
|
||||||
# 检查JWT密钥长度
|
# 检查JWT密钥长度
|
||||||
@@ -65,11 +72,11 @@ if [ "$JWT_SECRET" = "your-jwt-secret-key-change-this-in-production" ]; then
|
|||||||
echo "⚠️ 使用的是默认JWT密钥,生产环境中请更改"
|
echo "⚠️ 使用的是默认JWT密钥,生产环境中请更改"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ]; then
|
if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ] || [ "$DATABASE_PASSWORD" = "carrotskin123" ]; then
|
||||||
echo "⚠️ 使用的是默认数据库密码,生产环境中请更改"
|
echo "⚠️ 使用的是默认数据库密码,生产环境中请更改"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ]; then
|
if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ] || [ "$RUSTFS_ACCESS_KEY" = "rustfsadmin" ]; then
|
||||||
echo "⚠️ 使用的是默认RustFS凭证,生产环境中请更改"
|
echo "⚠️ 使用的是默认RustFS凭证,生产环境中请更改"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# CarrotSkin 开发环境启动脚本
|
|
||||||
|
|
||||||
echo "🚀 启动 CarrotSkin 开发环境..."
|
|
||||||
|
|
||||||
# 检查配置文件
|
|
||||||
if [ ! -f "configs/config.yaml" ]; then
|
|
||||||
echo "📝 复制配置文件..."
|
|
||||||
cp configs/config.yaml.example configs/config.yaml
|
|
||||||
echo "⚠️ 请编辑 configs/config.yaml 文件配置数据库和其他服务连接信息"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 检查依赖
|
|
||||||
echo "📦 检查依赖..."
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
# 生成Swagger文档
|
|
||||||
echo "📚 生成Swagger文档..."
|
|
||||||
if command -v swag &> /dev/null; then
|
|
||||||
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
|
|
||||||
else
|
|
||||||
echo "⚠️ swag工具未安装,请运行: go install github.com/swaggo/swag/cmd/swag@latest"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 启动应用
|
|
||||||
echo "🎯 启动应用..."
|
|
||||||
go run cmd/server/main.go
|
|
||||||
Reference in New Issue
Block a user