5 Commits

Author SHA1 Message Date
lafay
7d1c78f965 refactor: 移除全局单例、修复分层违规、统一错误与响应
Some checks failed
Build / build (push) Successful in 7m52s
Build / build-docker (push) Has been cancelled
2026-06-15 16:40:36 +08:00
lafay
d9de39a0a3 feat(app): 引入 uber-go/fx 重构 DI 装配层 2026-06-15 16:39:45 +08:00
lan
a111872b32 feat(auth): upgrade casbin to v3 and enhance connection pool configurations
Some checks failed
Build / build (push) Successful in 2m23s
Build / build-docker (push) Failing after 5m20s
- Upgrade casbin from v2 to v3 across go.mod and pkg/auth/casbin.go
- Add slide captcha verification to registration flow (CheckVerified, ConsumeVerified)
- Add DB wrapper with connection pool statistics and health checks
- Add Redis connection pool optimizations with stats and health monitoring
- Add new config options: ConnMaxLifetime, HealthCheckInterval, EnableRetryOnError
- Optimize slow query threshold from 200ms to 100ms
- Add ping with retry mechanism for database and Redis connections
2026-02-25 19:00:50 +08:00
lan
1da0ee1ed1 refactor(api): separate Yggdrasil routes from v1 API group
All checks were successful
Build / build (push) Successful in 2m15s
Build / build-docker (push) Successful in 1m2s
Move Yggdrasil API endpoints from /api/v1/yggdrasil to /api/yggdrasil for
better route organization and compatibility with standard Yggdrasil protocol.
Also upgrade Go to 1.25, update dependencies, and migrate email package from
jordan-wright/email to gopkg.in/mail.v2. Remove deprecated UUID design plan.
2026-02-24 02:18:15 +08:00
lan
29f0bad2bc feat(yggdrasil): implement standard error responses and UUID format improvements
All checks were successful
Build / build (push) Successful in 2m17s
Build / build-docker (push) Successful in 57s
- Add YggdrasilErrorResponse struct and standard error codes for protocol compliance
- Change UUID storage from varchar(36) to varchar(32) for unsigned format
- Add utility functions: GenerateUUID, FormatUUIDToNoDash, RandomHex
- Support unsigned query parameter in GetProfileByUUID endpoint
- Improve refresh token response with available profiles list
- Fix key pair retrieval to use correct database column (rsa_private_key)
- Update UUID validator to accept both 32-char and 36-char formats
- Add SignStringWithProfileRSA method for profile-specific signing
- Fix profile assignment validation in refresh token flow
2026-02-23 13:26:53 +08:00
80 changed files with 2152 additions and 2308 deletions

View File

@@ -41,19 +41,74 @@ DATABASE_PASSWORD=your_password_here
DATABASE_NAME=carrotskin DATABASE_NAME=carrotskin
DATABASE_SSL_MODE=disable DATABASE_SSL_MODE=disable
DATABASE_TIMEZONE=Asia/Shanghai DATABASE_TIMEZONE=Asia/Shanghai
# 连接池配置(优化后的默认值)
# 最大空闲连接数:在连接池中保持的最大空闲连接数
# 建议值CPU核心数 * 2 ~ CPU核心数 * 4
DATABASE_MAX_IDLE_CONNS=10 DATABASE_MAX_IDLE_CONNS=10
# 最大打开连接数:允许的最大并发连接数
# 建议值根据并发需求调整高并发场景可设置更高如200-500
DATABASE_MAX_OPEN_CONNS=100 DATABASE_MAX_OPEN_CONNS=100
# 连接最大生命周期:连接被重用前的最大存活时间
# 建议值30分钟到1小时避免长时间占用连接
DATABASE_CONN_MAX_LIFETIME=1h DATABASE_CONN_MAX_LIFETIME=1h
# 连接最大空闲时间:连接被关闭前的最大空闲时间
# 建议值5-15分钟避免长时间空闲占用资源
DATABASE_CONN_MAX_IDLE_TIME=10m DATABASE_CONN_MAX_IDLE_TIME=10m
# 连接获取超时:等待获取连接的超时时间(新增)
# 建议值1-5秒避免长时间阻塞
DATABASE_CONN_TIMEOUT=5s
# 查询超时:单次查询的最大执行时间(新增)
# 建议值5-30秒根据业务查询复杂度调整
DATABASE_QUERY_TIMEOUT=30s
# 慢查询阈值记录慢查询的阈值优化从200ms调整为100ms
# 超过此时间的查询将被记录为警告
DATABASE_SLOW_THRESHOLD=100ms
# 健康检查间隔:定期检查数据库连接健康的间隔(新增)
# 建议值30秒到5分钟
DATABASE_HEALTH_CHECK_INTERVAL=30s
# ============================================================================= # =============================================================================
# Redis配置 # Redis配置(优化后的默认值)
# ============================================================================= # =============================================================================
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD= REDIS_PASSWORD=
REDIS_DATABASE=0 REDIS_DATABASE=0
REDIS_POOL_SIZE=10
# 连接池配置(优化后的默认值)
# 连接池大小:允许的最大并发连接数
# 建议值CPU核心数 * 4 ~ CPU核心数 * 8根据并发需求调整
REDIS_POOL_SIZE=16
# 最小空闲连接数:在连接池中保持的最小空闲连接数
# 建议值CPU核心数 * 2 ~ CPU核心数 * 4
REDIS_MIN_IDLE_CONNS=8
# 最大重试次数:操作失败时的最大重试次数
REDIS_MAX_RETRIES=3
# 连接超时:建立连接的超时时间
# 建议值3-10秒
REDIS_DIAL_TIMEOUT=5s
# 读取超时:读取数据的超时时间
# 建议值3-5秒
REDIS_READ_TIMEOUT=3s
# 写入超时:写入数据的超时时间
# 建议值3-5秒
REDIS_WRITE_TIMEOUT=3s
# 连接池超时:等待获取连接的超时时间
# 建议值3-5秒
REDIS_POOL_TIMEOUT=4s
# 连接最大空闲时间:连接被关闭前的最大空闲时间
# 建议值5-15分钟避免长时间空闲占用资源
REDIS_CONN_MAX_IDLE_TIME=10m
# 连接最大生命周期:连接被重用前的最大存活时间
# 建议值15-30分钟避免长时间占用导致连接问题
REDIS_CONN_MAX_LIFETIME=30m
# 健康检查间隔定期检查Redis连接健康的间隔
# 建议值30秒到5分钟
REDIS_HEALTH_CHECK_INTERVAL=30s
# 错误时启用重试:操作失败时是否启用自动重试
# 建议值true生产环境开发环境可设为false
REDIS_ENABLE_RETRY_ON_ERROR=true
# ============================================================================= # =============================================================================
# RustFS对象存储配置 (S3兼容) # RustFS对象存储配置 (S3兼容)

View File

@@ -32,6 +32,7 @@ jobs:
GOOS: linux GOOS: linux
GOARCH: amd64 GOARCH: amd64
CGO_ENABLED: 0 CGO_ENABLED: 0
GOEXPERIMENT: greenteagc
run: go build -v -o mcauth-linux-amd64 ./cmd/server run: go build -v -o mcauth-linux-amd64 ./cmd/server
- name: Upload artifacts - name: Upload artifacts

1
.gitignore vendored
View File

@@ -110,3 +110,4 @@ service_coverage
.gitignore .gitignore
docs/ docs/
blessing skin材质渲染示例/ blessing skin材质渲染示例/
plan/

View File

@@ -10,170 +10,28 @@
package main package main
import ( import (
"context"
"log" "log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"carrotskin/internal/container" "carrotskin/internal/app"
"carrotskin/internal/handler"
"carrotskin/internal/middleware"
"carrotskin/internal/task"
"carrotskin/pkg/auth"
"carrotskin/pkg/config" "carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/email"
"carrotskin/pkg/logger"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"github.com/gin-gonic/gin" "go.uber.org/fx"
"go.uber.org/zap"
) )
// main 应用入口。通过 uber-go/fx 装配所有依赖并管理生命周期。
//
// fx.Run() 会:
// 1. 按 fx.Supply/Provide 构建依赖图
// 2. 执行所有 fx.Invoke迁移、路由注册、服务器/任务启动)
// 3. 监听 SIGINT/SIGTERM 信号
// 4. 按依赖逆序执行所有 OnStop 钩子(优雅关闭)
func main() { func main() {
// 初始化配置 // 加载配置(唯一的全局入口,配置本身是无状态的纯数据)
if err := config.Init(); err != nil { cfg, err := config.Load()
if err != nil {
log.Fatalf("配置加载失败: %v", err) log.Fatalf("配置加载失败: %v", err)
} }
cfg := config.MustGetConfig()
// 初始化日志 // 创建并运行 fx 应用
if err := logger.Init(cfg.Log); err != nil { fx.New(app.Module(cfg)).Run()
log.Fatalf("日志初始化失败: %v", err)
}
loggerInstance := logger.MustGetLogger()
defer loggerInstance.Sync()
// 初始化数据库
if err := database.Init(cfg.Database, loggerInstance); err != nil {
loggerInstance.Fatal("数据库初始化失败", zap.Error(err))
}
defer database.Close()
// 执行数据库迁移
if err := database.AutoMigrate(loggerInstance); err != nil {
loggerInstance.Fatal("数据库迁移失败", zap.Error(err))
}
// 初始化种子数据
if err := database.Seed(loggerInstance); err != nil {
loggerInstance.Fatal("种子数据初始化失败", zap.Error(err))
}
// 初始化JWT服务
if err := auth.Init(cfg.JWT); err != nil {
loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err))
}
// 初始化Redis开发/测试环境失败时会自动回退到miniredis
if err := redis.Init(cfg.Redis, loggerInstance); err != nil {
loggerInstance.Fatal("Redis初始化失败", zap.Error(err))
}
defer redis.Close()
// 记录Redis模式
if redis.IsUsingMiniRedis() {
loggerInstance.Info("使用miniredis进行开发/测试")
} else {
loggerInstance.Info("使用生产Redis")
}
// 初始化对象存储 (RustFS - S3兼容)
var storageClient *storage.StorageClient
if err := storage.Init(cfg.RustFS); err != nil {
loggerInstance.Warn("对象存储连接失败,某些功能可能不可用", zap.Error(err))
} else {
storageClient = storage.MustGetClient()
loggerInstance.Info("对象存储连接成功")
}
// 初始化邮件服务
if err := email.Init(cfg.Email, loggerInstance); err != nil {
loggerInstance.Fatal("邮件服务初始化失败", zap.Error(err))
}
emailServiceInstance := email.MustGetService()
// 初始化Casbin权限服务
casbinService, err := auth.NewCasbinService(database.MustGetDB(), cfg.Casbin.ModelPath, loggerInstance)
if err != nil {
loggerInstance.Fatal("Casbin服务初始化失败", zap.Error(err))
}
// 创建依赖注入容器
c := container.NewContainer(
database.MustGetDB(),
redis.MustGetClient(),
loggerInstance,
auth.MustGetJWTService(),
casbinService,
storageClient,
emailServiceInstance,
)
// 设置Gin模式
if cfg.Server.Mode == "production" {
gin.SetMode(gin.ReleaseMode)
}
// 创建路由
router := gin.New()
// 禁用自动重定向允许API路径带或不带/结尾都能正常访问
router.RedirectTrailingSlash = false
router.RedirectFixedPath = false
// 添加中间件
router.Use(middleware.Logger(loggerInstance))
router.Use(middleware.Recovery(loggerInstance))
router.Use(middleware.CORS())
// 使用依赖注入方式注册路由
handler.RegisterRoutesWithDI(router, c)
// 启动后台任务Token已迁移到Redis不再需要清理任务
// 如需使用数据库Token存储可以恢复TokenCleanupTask
taskRunner := task.NewRunner(loggerInstance)
taskCtx, taskCancel := context.WithCancel(context.Background())
defer taskCancel()
taskRunner.Start(taskCtx)
// 创建HTTP服务器
srv := &http.Server{
Addr: cfg.Server.Port,
Handler: router,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
// 启动服务器
go func() {
loggerInstance.Info("服务器启动", zap.String("port", cfg.Server.Port))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
loggerInstance.Fatal("服务器启动失败", zap.Error(err))
}
}()
// 等待中断信号优雅关闭
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
loggerInstance.Info("正在关闭服务器...")
// 停止后台任务
taskCancel()
taskRunner.Wait()
// 设置关闭超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
loggerInstance.Fatal("服务器强制关闭", zap.Error(err))
}
loggerInstance.Info("服务器已关闭")
} }

126
go.mod
View File

@@ -1,24 +1,27 @@
module carrotskin module carrotskin
go 1.24.0 go 1.25.0
toolchain go1.24.2 // Enable new GC (Generational GC) for Go 1.25
// GOGC=100 (default), GOMEMLIMIT=0 (no limit)
// Build with: go build -gcflags="-G=3" to enable generational GC
require ( require (
github.com/alicebob/miniredis/v2 v2.31.1 github.com/alicebob/miniredis/v2 v2.36.1
github.com/casbin/casbin/v2 v2.123.0 github.com/casbin/casbin/v3 v3.10.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.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/minio/minio-go/v7 v7.0.98
github.com/minio/minio-go/v7 v7.0.97 github.com/redis/go-redis/v9 v9.18.0
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/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/gin-swagger v1.6.1
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/fx v1.24.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
gopkg.in/mail.v2 v2.3.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/driver/sqlite v1.6.0
@@ -26,79 +29,86 @@ require (
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
github.com/bmatcuk/doublestar/v4 v4.6.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.5.0 // indirect
github.com/casbin/govaluate v1.3.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/glebarez/go-sqlite v1.20.3 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.7.0 // indirect github.com/glebarez/sqlite v1.11.0 // 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.19.5 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.20.4 // indirect github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/swag v0.19.15 // 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.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang-sql/sqlexp v0.1.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/josharian/intern v1.0.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/microsoft/go-mssqldb v1.9.6 // indirect
github.com/microsoft/go-mssqldb v1.7.2 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/crc64nvme v1.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // 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.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tinylib/msgp v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/yuin/gopher-lua v1.1.0 // indirect github.com/tinylib/msgp v1.6.3 // indirect
go.uber.org/mock v0.5.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/image v0.33.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/mod v0.30.0 // indirect go.uber.org/dig v1.19.0 // indirect
golang.org/x/sync v0.18.0 // indirect go.uber.org/mock v0.6.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gorm.io/driver/mysql v1.6.0 // indirect gorm.io/driver/mysql v1.6.0 // indirect
gorm.io/driver/sqlserver v1.6.0 // indirect gorm.io/driver/sqlserver v1.6.3 // indirect
gorm.io/plugin/dbresolver v1.6.0 // indirect gorm.io/plugin/dbresolver v1.6.2 // indirect
modernc.org/libc v1.22.2 // indirect modernc.org/libc v1.68.0 // indirect
modernc.org/mathutil v1.5.0 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.5.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.20.3 // indirect modernc.org/sqlite v1.46.1 // indirect
) )
require ( require (
github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/casbin/gorm-adapter/v3 v3.39.0 github.com/casbin/gorm-adapter/v3 v3.41.0
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -116,13 +126,11 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.48.0
golang.org/x/crypto v0.45.0 golang.org/x/net v0.50.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/tools v0.42.0 // indirect
golang.org/x/tools v0.39.0 // indirect google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

398
go.sum
View File

@@ -1,56 +1,55 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 h1:DzHpqpoJVaCgOUdVHxE8QB52S6NiVdDQvGlny1qvPqA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y=
github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
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=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v2 v2.123.0 h1:UkiMllBgn3MrwHGiZTDFVTV9up+W2CRLufZwKiuAmpA= github.com/casbin/casbin/v3 v3.10.0 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQuHs=
github.com/casbin/casbin/v2 v2.123.0/go.mod h1:Ee33aqGrmES+GNL17L0h9X28wXuo829wnNUnS0edAco= github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
github.com/casbin/gorm-adapter/v3 v3.39.0 h1:k15txH6vE4796MuA+LFcU8I1vMjutklyzMXfjDz7lzo= github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
github.com/casbin/gorm-adapter/v3 v3.39.0/go.mod h1:kjXoK8MqA3E/CcqEF2l3SCkhJj1YiHVR6SF0LMvJoH4= github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA=
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
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/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -67,87 +66,102 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= 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/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/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.7.0 h1:A7Xj/KN2Lvie4Z4rrgQHY8MsbebX3NyWsL3n2i82MVI= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.7.0/go.mod h1:PkeevrRlF/1BhQBCnzcMWzgrIk7IOop+qS2jUYLfHhk= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
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.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= 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=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.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.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
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=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
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/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
@@ -155,20 +169,16 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -181,58 +191,58 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
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.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= 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/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 h1:VstopitMQi3hZP0fzvnsLmzXZdQGc4bEcgu24cp+d4M= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -247,11 +257,12 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@@ -263,8 +274,8 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= 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 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/tinylib/msgp v1.6.3/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=
@@ -274,59 +285,74 @@ 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.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
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=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
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-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
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=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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=
@@ -334,76 +360,82 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
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=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
@@ -414,18 +446,38 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/dbresolver v1.6.0 h1:XvKDeOtTn1EIX6s4SrKpEH82q0gXVemhYjbYZFGFVcw= gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.0/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/sqlite v1.20.3 h1:SqGJMMxjj1PHusLxdYxeQSodg7Jxn9WWkaAQjKrntZs= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/sqlite v1.20.3/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,138 @@
package app
import (
"context"
"net/http"
"carrotskin/internal/container"
"carrotskin/internal/handler"
"carrotskin/internal/middleware"
"carrotskin/internal/task"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/email"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
// BootstrapModule 注册 Container、路由注册、HTTP 服务器与后台任务的生命周期。
//
// 设计说明:当前所有 Handler 仍依赖 *container.Container阶段6将逐步解耦为
// 直接依赖各 Service 接口)。本 Module 由 fx 注入基础设施,聚合为 Container
// 再用它注册路由、启动服务器与后台任务。当 fx.Run() 接收到 SIGINT/SIGTERM 时,
// 会按依赖逆序执行所有 OnStop 钩子,实现优雅关闭。
var BootstrapModule = fx.Options(
// Gin 路由引擎
fx.Provide(provideRouter),
// 由 fx 注入的基础设施聚合为 ContainerContainer 内部会构造 Repository/Service
fx.Provide(provideContainer),
// 数据库迁移与种子数据infra 就绪后执行)
fx.Invoke(runMigrate),
// 注册所有 HTTP 路由
fx.Invoke(registerRoutes),
// HTTP 服务器生命周期(启动/优雅关闭)
fx.Invoke(registerServerLifecycle),
// 后台任务调度器生命周期
fx.Invoke(registerTaskLifecycle),
)
// provideRouter 创建 Gin 引擎并挂载全局中间件。
func provideRouter(cfg *config.Config, logger *zap.Logger) *gin.Engine {
if cfg.Server.Mode == "production" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
// 禁用自动重定向允许API路径带或不带/结尾都能正常访问
router.RedirectTrailingSlash = false
router.RedirectFixedPath = false
router.Use(middleware.Logger(logger))
router.Use(middleware.Recovery(logger))
router.Use(middleware.CORS(cfg))
return router
}
// provideContainer 将所有 fx 管理的基础设施聚合为 Container。
// Container.NewContainer 内部会基于这些 infra 构造 Repository 与 Service。
// 注意email.Service 可能为 nil当 EMAIL_ENABLED=false 时Container 会降级处理。
func provideContainer(
cfg *config.Config,
db *gorm.DB,
redisClient *redis.Client,
logger *zap.Logger,
jwt *auth.JWTService,
casbin *auth.CasbinService,
storageClient *storage.StorageClient,
emailService *email.Service,
) *container.Container {
return container.NewContainer(
cfg, db, redisClient, logger, jwt, casbin, storageClient, emailService,
)
}
// runMigrate 执行数据库迁移与种子数据初始化。
func runMigrate(db *gorm.DB, logger *zap.Logger) error {
if err := database.AutoMigrateWithDB(db, logger); err != nil {
return err
}
return database.SeedWithDB(db, logger)
}
// registerRoutes 注册所有 HTTP 路由。
func registerRoutes(router *gin.Engine, c *container.Container) {
handler.RegisterRoutesWithDI(router, c)
}
// registerServerLifecycle 注册 HTTP 服务器的启动与优雅关闭。
func registerServerLifecycle(lc fx.Lifecycle, router *gin.Engine, cfg *config.Config, logger *zap.Logger) {
srv := &http.Server{
Addr: cfg.Server.Port,
Handler: router,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
logger.Info("服务器启动", zap.String("port", cfg.Server.Port))
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal("服务器启动失败", zap.Error(err))
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
logger.Info("正在关闭服务器...")
return srv.Shutdown(ctx)
},
})
}
// registerTaskLifecycle 注册后台任务调度器的启动与停止。
func registerTaskLifecycle(lc fx.Lifecycle, logger *zap.Logger) {
runner := task.NewRunner(logger)
ctx, cancel := context.WithCancel(context.Background())
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
runner.Start(ctx)
return nil
},
OnStop: func(_ context.Context) error {
cancel()
runner.Wait()
return nil
},
})
}

View File

@@ -0,0 +1,8 @@
package app
// HandlerModule 已在阶段4移除。
//
// 当前阶段Handler 仍由 internal/container.Container + handler.RegisterRoutesWithDI
// 构造与注册(见 container_module.go 的 registerRoutes
//
// TODO(阶段6): Handler 将改为直接依赖各 Service 接口,由 fx 直接 Provide 与注册。

View File

@@ -0,0 +1,108 @@
package app
import (
"context"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/email"
"carrotskin/pkg/logger"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
// InfraModule 注册所有基础设施依赖Logger/DB/Redis/Storage/Email/JWT/Casbin。
// 每个需要资源释放的组件都通过 fx.Lifecycle 注册 OnStop 钩子,
// 彻底替代 main.go 里分散的 defer xxx.Close()。
var InfraModule = fx.Options(
// Logger
fx.Provide(logger.New),
// Database: 同时暴露 *database.DB封装带连接池统计和 *gorm.DB裸 GORM
fx.Provide(provideDatabase),
// 暴露 *gorm.DB 供 Repository/Casbin 等消费
fx.Provide(func(d *database.DB) *gorm.DB { return d.DB }),
// Redis含 miniredis 测试回退)
fx.Provide(provideRedis),
// 对象存储S3 兼容)
fx.Provide(provideStorage),
// 邮件服务
fx.Provide(email.NewService),
// JWT 服务(应用层认证)
fx.Provide(func(cfg *config.Config) *auth.JWTService {
return auth.NewJWTService(cfg.JWT.Secret, cfg.JWT.ExpireHours)
}),
// Casbin 权限服务
fx.Provide(func(db *gorm.DB, cfg *config.Config, log *zap.Logger) (*auth.CasbinService, error) {
return auth.NewCasbinService(db, cfg.Casbin.ModelPath, log)
}),
)
// provideDatabase 创建数据库连接并注册关闭钩子。
func provideDatabase(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*database.DB, error) {
db, err := database.New(cfg.Database)
if err != nil {
return nil, err
}
lc.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
log.Info("正在关闭数据库连接")
return db.Close()
},
})
return db, nil
}
// provideRedis 创建 Redis 客户端并注册关闭钩子。
// 开发/测试环境下若连接失败会自动回退到 miniredis。
func provideRedis(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*redis.Client, error) {
client, err := redis.New(cfg.Redis, log)
if err != nil {
// 检查是否允许回退到 miniredis仅开发/测试环境)
if redis.AllowFallbackToMiniRedis() {
log.Warn("Redis连接失败尝试使用miniredis回退", zap.Error(err))
client, err = redis.InitMiniRedis(log)
if err != nil {
return nil, err
}
log.Info("已回退到miniredis用于开发/测试环境")
} else {
return nil, err
}
}
lc.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
log.Info("正在关闭 Redis 连接")
return client.Close()
},
})
return client, nil
}
// provideStorage 创建对象存储客户端并注册关闭钩子。
// 存储服务在配置缺失时可能是可选的,失败时返回 error 让调用方决定是否降级。
func provideStorage(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*storage.StorageClient, error) {
client, err := storage.NewStorage(cfg.RustFS)
if err != nil {
return nil, err
}
// MinIO 客户端无需显式 Close但保留钩子以备未来扩展
lc.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
log.Info("对象存储客户端已停止")
return nil
},
})
return client, nil
}

30
internal/app/module.go Normal file
View File

@@ -0,0 +1,30 @@
// Package app 提供 uber-go/fx 应用装配层。
//
// 该包用 fx 的 Provider/Lifecycle 取代了 pkg/*/manager.go 的全局单例和
// internal/container 的手动 DI是整个应用唯一的依赖装配入口。
//
// 使用方式:
//
// cfg, err := config.Load()
// if err != nil { log.Fatal(err) }
// fx.New(app.Module(cfg)).Run()
package app
import (
"carrotskin/pkg/config"
"go.uber.org/fx"
)
// Module 返回应用的根 fx.Option聚合所有子模块。
// cfg 通过 fx.Supply 注入,供各 Provider 消费其子配置结构。
//
// 当前阶段fx 管理 infra + Container 聚合 + 路由/服务器/任务生命周期。
// Container 内部构造 Repository 与 Service阶段6后将逐步解耦为 fx 直接管理 Service
func Module(cfg *config.Config) fx.Option {
return fx.Options(
fx.Supply(cfg),
InfraModule,
BootstrapModule,
)
}

View File

@@ -0,0 +1,13 @@
package app
// RepositoryModule 已在阶段4移除。
//
// 当前阶段Repository 由 internal/container.Container 内部构造。
//
// TODO(阶段6): Container 移除后Repository 将回到此 Module 由 fx 直接管理:
//
// var RepositoryModule = fx.Options(
// fx.Provide(repository.NewUserRepository),
// fx.Provide(repository.NewProfileRepository),
// ...
// )

View File

@@ -0,0 +1,7 @@
package app
// ServerModule 已在阶段4移除。
//
// 当前阶段HTTP 服务器生命周期由 container_module.go 的 registerServerLifecycle 管理。
//
// TODO(阶段6): 此处将作为独立 Module 重新启用。

View File

@@ -0,0 +1,15 @@
package app
// ServiceModule 已在阶段4移除。
//
// 当前阶段Service 由 internal/container.Container 内部构造Container 聚合了
// 所有 fx 注入的基础设施,在其 NewContainer 方法里创建 Repository 与 Service
//
// TODO(阶段6): 当 Handler 解耦为直接依赖各 Service 接口后Container 将被移除,
// 届时 Service 将回到此 Module 由 fx 直接管理:
//
// var ServiceModule = fx.Options(
// fx.Provide(service.NewUserService),
// fx.Provide(service.NewProfileService),
// ...
// )

View File

@@ -0,0 +1,7 @@
package app
// TaskModule 已在阶段4移除。
//
// 当前阶段,后台任务生命周期由 container_module.go 的 registerTaskLifecycle 管理。
//
// TODO(阶段6): 此处将作为独立 Module 重新启用。

View File

@@ -4,10 +4,12 @@ import (
"carrotskin/internal/repository" "carrotskin/internal/repository"
"carrotskin/internal/service" "carrotskin/internal/service"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"carrotskin/pkg/config"
"carrotskin/pkg/database" "carrotskin/pkg/database"
"carrotskin/pkg/email" "carrotskin/pkg/email"
"carrotskin/pkg/redis" "carrotskin/pkg/redis"
"carrotskin/pkg/storage" "carrotskin/pkg/storage"
"context"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -17,6 +19,9 @@ import (
// Container 依赖注入容器 // Container 依赖注入容器
// 集中管理所有依赖,便于测试和维护 // 集中管理所有依赖,便于测试和维护
type Container struct { type Container struct {
// 配置
Config *config.Config
// 基础设施依赖 // 基础设施依赖
DB *gorm.DB DB *gorm.DB
Redis *redis.Client Redis *redis.Client
@@ -40,20 +45,20 @@ type Container struct {
TokenService service.TokenService TokenService service.TokenService
YggdrasilService service.YggdrasilService YggdrasilService service.YggdrasilService
VerificationService service.VerificationService VerificationService service.VerificationService
SecurityService service.SecurityService
CaptchaService service.CaptchaService CaptchaService service.CaptchaService
SignatureService *service.SignatureService SignatureService *service.SignatureService
} }
// NewContainer 创建依赖容器 // NewContainer 创建依赖容器
func NewContainer( func NewContainer(
cfg *config.Config,
db *gorm.DB, db *gorm.DB,
redisClient *redis.Client, redisClient *redis.Client,
logger *zap.Logger, logger *zap.Logger,
jwtService *auth.JWTService, jwtService *auth.JWTService,
casbinService *auth.CasbinService, casbinService *auth.CasbinService,
storageClient *storage.StorageClient, storageClient *storage.StorageClient,
emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖 emailService *email.Service,
) *Container { ) *Container {
// 创建缓存管理器 // 创建缓存管理器
cacheManager := database.NewCacheManager(redisClient, database.CacheConfig{ cacheManager := database.NewCacheManager(redisClient, database.CacheConfig{
@@ -71,6 +76,7 @@ func NewContainer(
}) })
c := &Container{ c := &Container{
Config: cfg,
DB: db, DB: db,
Redis: redisClient, Redis: redisClient,
Logger: logger, Logger: logger,
@@ -92,14 +98,17 @@ 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, jwtService, redisClient, cacheManager, storageClient, logger) c.UserService = service.NewUserService(cfg, c.UserRepo, 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, db)
// 获取Yggdrasil私钥并创建JWT服务TokenService需要 // 获取Yggdrasil私钥并创建JWT服务TokenService需要
// 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT // 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT
// 但SignatureService已经作为依赖注入降低了耦合度 // 但SignatureService已经作为依赖注入降低了耦合度
_, privateKey, err := c.SignatureService.GetOrCreateYggdrasilKeyPair() // 使用后台 ctx启动阶段不依赖请求 ctx
initCtx, cancelInit := context.WithTimeout(context.Background(), 30*time.Second)
_, privateKey, err := c.SignatureService.GetOrCreateYggdrasilKeyPair(initCtx)
cancelInit()
if err != nil { if err != nil {
logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err)) logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err))
} }
@@ -121,149 +130,15 @@ func NewContainer(
c.TokenService = service.NewTokenServiceRedis(tokenStore, c.ClientRepo, c.ProfileRepo, yggdrasilJWT, logger) 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(c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.TextureRepo, c.SignatureService, redisClient, logger, c.TokenService)
// 初始化其他服务 // 初始化其他服务
c.SecurityService = service.NewSecurityService(redisClient) c.CaptchaService = service.NewCaptchaService(cfg, redisClient, logger)
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
// 初始化VerificationService需要email.Service // 初始化VerificationService需要email.Service
if emailService != nil { if emailService != nil {
if emailSvc, ok := emailService.(*email.Service); ok { c.VerificationService = service.NewVerificationService(cfg, redisClient, emailService)
c.VerificationService = service.NewVerificationService(redisClient, emailSvc)
}
} }
return c return c
} }
// NewTestContainer 创建测试用容器可注入mock依赖
func NewTestContainer(opts ...Option) *Container {
c := &Container{}
for _, opt := range opts {
opt(c)
}
return c
}
// Option 容器配置选项
type Option func(*Container)
// WithDB 设置数据库连接
func WithDB(db *gorm.DB) Option {
return func(c *Container) {
c.DB = db
}
}
// WithRedis 设置Redis客户端
func WithRedis(redis *redis.Client) Option {
return func(c *Container) {
c.Redis = redis
}
}
// WithLogger 设置日志
func WithLogger(logger *zap.Logger) Option {
return func(c *Container) {
c.Logger = logger
}
}
// WithJWT 设置JWT服务
func WithJWT(jwt *auth.JWTService) Option {
return func(c *Container) {
c.JWT = jwt
}
}
// WithStorage 设置存储客户端
func WithStorage(storage *storage.StorageClient) Option {
return func(c *Container) {
c.Storage = storage
}
}
// WithUserRepo 设置用户仓储
func WithUserRepo(repo repository.UserRepository) Option {
return func(c *Container) {
c.UserRepo = repo
}
}
// WithProfileRepo 设置档案仓储
func WithProfileRepo(repo repository.ProfileRepository) Option {
return func(c *Container) {
c.ProfileRepo = repo
}
}
// WithTextureRepo 设置材质仓储
func WithTextureRepo(repo repository.TextureRepository) Option {
return func(c *Container) {
c.TextureRepo = repo
}
}
// WithUserService 设置用户服务
func WithUserService(svc service.UserService) Option {
return func(c *Container) {
c.UserService = svc
}
}
// WithProfileService 设置档案服务
func WithProfileService(svc service.ProfileService) Option {
return func(c *Container) {
c.ProfileService = svc
}
}
// WithTextureService 设置材质服务
func WithTextureService(svc service.TextureService) Option {
return func(c *Container) {
c.TextureService = svc
}
}
// WithTokenService 设置令牌服务
func WithTokenService(svc service.TokenService) Option {
return func(c *Container) {
c.TokenService = svc
}
}
// WithYggdrasilRepo 设置Yggdrasil仓储
func WithYggdrasilRepo(repo repository.YggdrasilRepository) Option {
return func(c *Container) {
c.YggdrasilRepo = repo
}
}
// WithYggdrasilService 设置Yggdrasil服务
func WithYggdrasilService(svc service.YggdrasilService) Option {
return func(c *Container) {
c.YggdrasilService = svc
}
}
// WithVerificationService 设置验证码服务
func WithVerificationService(svc service.VerificationService) Option {
return func(c *Container) {
c.VerificationService = svc
}
}
// WithSecurityService 设置安全服务
func WithSecurityService(svc service.SecurityService) Option {
return func(c *Container) {
c.SecurityService = svc
}
}
// WithCaptchaService 设置验证码服务
func WithCaptchaService(svc service.CaptchaService) Option {
return func(c *Container) {
c.CaptchaService = svc
}
}

View File

@@ -60,6 +60,10 @@ var (
ErrUUIDRequired = errors.New("UUID不能为空") ErrUUIDRequired = errors.New("UUID不能为空")
ErrCertificateGenerate = errors.New("生成证书失败") ErrCertificateGenerate = errors.New("生成证书失败")
// Yggdrasil协议标准错误
ErrYggForbiddenOperation = errors.New("ForbiddenOperationException")
ErrYggIllegalArgument = errors.New("IllegalArgumentException")
// 通用错误 // 通用错误
ErrBadRequest = errors.New("请求参数错误") ErrBadRequest = errors.New("请求参数错误")
ErrInternalServer = errors.New("服务器内部错误") ErrInternalServer = errors.New("服务器内部错误")
@@ -121,20 +125,32 @@ func NewInternalError(message string, err error) *AppError {
return NewAppError(500, message, err) return NewAppError(500, message, err)
} }
// Is 检查错误是否匹配 // 注意:原此处的 Is/As/Wrap 函数(透传标准库)已删除。
func Is(err, target error) bool { // 请直接使用标准库 errors.Is / errors.As / fmt.Errorf。
return errors.Is(err, target)
// YggdrasilErrorResponse Yggdrasil协议标准错误响应格式
type YggdrasilErrorResponse struct {
Error string `json:"error"` // 错误的简要描述(机器可读)
ErrorMessage string `json:"errorMessage"` // 错误的详细信息(人类可读)
Cause string `json:"cause,omitempty"` // 该错误的原因(可选)
} }
// As 尝试将错误转换为指定类型 // NewYggdrasilErrorResponse 创建Yggdrasil标准错误响应
func As(err error, target interface{}) bool { func NewYggdrasilErrorResponse(error, errorMessage, cause string) *YggdrasilErrorResponse {
return errors.As(err, target) return &YggdrasilErrorResponse{
} Error: error,
ErrorMessage: errorMessage,
// Wrap 包装错误 Cause: cause,
func Wrap(err error, message string) error {
if err == nil {
return nil
} }
return fmt.Errorf("%s: %w", message, err)
} }
// YggdrasilErrorCodes Yggdrasil协议错误码常量
const (
// ForbiddenOperationException 错误消息
YggErrInvalidToken = "Invalid token."
YggErrInvalidCredentials = "Invalid credentials. Invalid username or password."
// IllegalArgumentException 错误消息
YggErrProfileAlreadyAssigned = "Access token already has a profile assigned."
)

View File

@@ -15,24 +15,12 @@ func TestAppErrorBasics(t *testing.T) {
if got := appErr.Error(); got != "bad: root" { if got := appErr.Error(); got != "bad: root" {
t.Fatalf("unexpected Error(): %s", got) t.Fatalf("unexpected Error(): %s", got)
} }
if !Is(appErr, root) { // 使用标准库 errors.Is/As包级 Is/As 已移除)
if !errors.Is(appErr, root) {
t.Fatalf("Is should match wrapped error") t.Fatalf("Is should match wrapped error")
} }
var target *AppError var target *AppError
if !As(appErr, &target) { if !errors.As(appErr, &target) {
t.Fatalf("As should succeed") t.Fatalf("As should succeed")
} }
} }
func TestWrap(t *testing.T) {
if Wrap(nil, "msg") != nil {
t.Fatalf("Wrap nil should return nil")
}
err := errors.New("base")
wrapped := Wrap(err, "ctx")
if wrapped.Error() != "ctx: base" {
t.Fatalf("wrap message mismatch: %v", wrapped)
}
}

View File

@@ -1,9 +1,11 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/model" "carrotskin/internal/model"
@@ -46,11 +48,16 @@ func (h *AdminHandler) SetUserRole(c *gin.Context) {
return return
} }
// 获取当前操作者ID // 获取当前操作者ID(安全类型断言,防止中间件异常时 panic
operatorID, _ := c.Get("user_id") operatorIDVal, _ := c.Get("user_id")
operatorID, ok := operatorIDVal.(int64)
if !ok {
RespondServerError(c, "操作者ID类型错误", nil)
return
}
// 不能修改自己的角色 // 不能修改自己的角色
if req.UserID == operatorID.(int64) { if req.UserID == operatorID {
c.JSON(http.StatusBadRequest, model.NewErrorResponse( c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest, model.CodeBadRequest,
"不能修改自己的角色", "不能修改自己的角色",
@@ -59,9 +66,13 @@ func (h *AdminHandler) SetUserRole(c *gin.Context) {
return return
} }
// 检查目标用户是否存在 // 检查目标用户是否存在(区分 DB 错误与"用户不存在"
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID) targetUser, err := h.container.UserService.GetByID(c.Request.Context(), req.UserID)
if err != nil || targetUser == nil { if err != nil {
RespondServerError(c, "查询用户失败", err)
return
}
if targetUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse( c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound, model.CodeNotFound,
"用户不存在", "用户不存在",
@@ -71,16 +82,13 @@ func (h *AdminHandler) SetUserRole(c *gin.Context) {
} }
// 更新用户角色 // 更新用户角色
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{ if err := h.container.UserService.SetRole(c.Request.Context(), req.UserID, req.Role); err != nil {
"role": req.Role,
})
if err != nil {
RespondServerError(c, "更新用户角色失败", err) RespondServerError(c, "更新用户角色失败", err)
return return
} }
h.container.Logger.Info("管理员修改用户角色", h.container.Logger.Info("管理员修改用户角色",
zap.Int64("operator_id", operatorID.(int64)), zap.Int64("operator_id", operatorID),
zap.Int64("target_user_id", req.UserID), zap.Int64("target_user_id", req.UserID),
zap.String("new_role", req.Role), zap.String("new_role", req.Role),
) )
@@ -114,13 +122,12 @@ func (h *AdminHandler) GetUserList(c *gin.Context) {
pageSize = 20 pageSize = 20
} }
// 使用数据库直接查询用户列表 // 通过 Service 层查询用户列表
var users []model.User users, total, err := h.container.UserService.ListUsers(c.Request.Context(), page, pageSize)
var total int64 if err != nil {
RespondServerError(c, "获取用户列表失败", err)
db := h.container.DB return
db.Model(&model.User{}).Count(&total) }
db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&users)
// 构建响应(隐藏敏感信息) // 构建响应(隐藏敏感信息)
userList := make([]gin.H, len(users)) userList := make([]gin.H, len(users))
@@ -163,7 +170,7 @@ func (h *AdminHandler) GetUserDetail(c *gin.Context) {
return return
} }
user, err := h.container.UserRepo.FindByID(c.Request.Context(), userID) user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
if err != nil || user == nil { if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse( c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound, model.CodeNotFound,
@@ -212,10 +219,15 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
return return
} }
operatorID, _ := c.Get("user_id") operatorIDVal, _ := c.Get("user_id")
operatorID, ok := operatorIDVal.(int64)
if !ok {
RespondServerError(c, "操作者ID类型错误", nil)
return
}
// 不能修改自己的状态 // 不能修改自己的状态
if req.UserID == operatorID.(int64) { if req.UserID == operatorID {
c.JSON(http.StatusBadRequest, model.NewErrorResponse( c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest, model.CodeBadRequest,
"不能修改自己的状态", "不能修改自己的状态",
@@ -224,9 +236,13 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
return return
} }
// 检查目标用户是否存在 // 检查目标用户是否存在(区分 DB 错误与"用户不存在"
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID) targetUser, err := h.container.UserService.GetByID(c.Request.Context(), req.UserID)
if err != nil || targetUser == nil { if err != nil {
RespondServerError(c, "查询用户失败", err)
return
}
if targetUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse( c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound, model.CodeNotFound,
"用户不存在", "用户不存在",
@@ -236,10 +252,7 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
} }
// 更新用户状态 // 更新用户状态
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{ if err := h.container.UserService.SetStatus(c.Request.Context(), req.UserID, req.Status); err != nil {
"status": req.Status,
})
if err != nil {
RespondServerError(c, "更新用户状态失败", err) RespondServerError(c, "更新用户状态失败", err)
return return
} }
@@ -247,7 +260,7 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status] statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status]
h.container.Logger.Info("管理员修改用户状态", h.container.Logger.Info("管理员修改用户状态",
zap.Int64("operator_id", operatorID.(int64)), zap.Int64("operator_id", operatorID),
zap.Int64("target_user_id", req.UserID), zap.Int64("target_user_id", req.UserID),
zap.Int16("new_status", req.Status), zap.Int16("new_status", req.Status),
) )
@@ -277,30 +290,30 @@ func (h *AdminHandler) DeleteTexture(c *gin.Context) {
return return
} }
operatorID, _ := c.Get("user_id") operatorIDVal, _ := c.Get("user_id")
operatorID, ok := operatorIDVal.(int64)
// 检查材质是否存在 if !ok {
var texture model.Texture RespondServerError(c, "操作者ID类型错误", nil)
if err := h.container.DB.First(&texture, textureID).Error; err != nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"材质不存在",
nil,
))
return return
} }
// 删除材质 // 通过 Service 删除材质Service 会检查存在性)
if err := h.container.DB.Delete(&texture).Error; err != nil { if err := h.container.TextureService.AdminDelete(c.Request.Context(), textureID); err != nil {
if errors.Is(err, apperrors.ErrTextureNotFound) {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"材质不存在",
nil,
))
return
}
RespondServerError(c, "删除材质失败", err) RespondServerError(c, "删除材质失败", err)
return return
} }
h.container.Logger.Info("管理员删除材质", h.container.Logger.Info("管理员删除材质",
zap.Int64("operator_id", operatorID.(int64)), zap.Int64("operator_id", operatorID),
zap.Int64("texture_id", textureID), zap.Int64("texture_id", textureID),
zap.Int64("uploader_id", texture.UploaderID),
zap.String("texture_name", texture.Name),
) )
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{ c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
@@ -330,12 +343,12 @@ func (h *AdminHandler) GetTextureList(c *gin.Context) {
pageSize = 20 pageSize = 20
} }
var textures []model.Texture // 通过 Service 层查询材质列表
var total int64 textures, total, err := h.container.TextureService.ListForAdmin(c.Request.Context(), page, pageSize)
if err != nil {
db := h.container.DB RespondServerError(c, "获取材质列表失败", err)
db.Model(&model.Texture{}).Count(&total) return
db.Preload("Uploader").Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&textures) }
// 构建响应 // 构建响应
textureList := make([]gin.H, len(textures)) textureList := make([]gin.H, len(textures))

View File

@@ -4,7 +4,6 @@ import (
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/service" "carrotskin/internal/service"
"carrotskin/internal/types" "carrotskin/internal/types"
"carrotskin/pkg/email"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
@@ -41,9 +40,23 @@ func (h *AuthHandler) Register(c *gin.Context) {
return return
} }
// 验证滑动验证码(检查是否已验证)
if ok, err := h.container.CaptchaService.CheckVerified(c.Request.Context(), req.CaptchaID); err != nil || !ok {
h.logger.Warn("滑动验证码验证失败", zap.String("captcha_id", req.CaptchaID), zap.Error(err))
RespondBadRequest(c, "滑动验证码验证失败", nil)
return
}
// 使用 defer 确保验证码在函数返回前被消耗(不管成功还是失败)
defer func() {
if err := h.container.CaptchaService.ConsumeVerified(c.Request.Context(), req.CaptchaID); err != nil {
h.logger.Warn("消耗验证码失败", zap.String("captcha_id", req.CaptchaID), zap.Error(err))
}
}()
// 验证邮箱验证码 // 验证邮箱验证码
if err := h.container.VerificationService.VerifyCode(c.Request.Context(), req.Email, req.VerificationCode, service.VerificationTypeRegister); err != nil { if err := h.container.VerificationService.VerifyCode(c.Request.Context(), req.Email, req.VerificationCode, service.VerificationTypeRegister); err != nil {
h.logger.Warn("验证码验证失败", zap.String("email", req.Email), zap.Error(err)) h.logger.Warn("邮箱验证码验证失败", zap.String("email", req.Email), zap.Error(err))
RespondBadRequest(c, err.Error(), nil) RespondBadRequest(c, err.Error(), nil)
return return
} }
@@ -163,8 +176,3 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
RespondSuccess(c, gin.H{"message": "密码重置成功"}) RespondSuccess(c, gin.H{"message": "密码重置成功"})
} }
// getEmailService 获取邮件服务(暂时使用全局方式,后续可改为依赖注入)
func (h *AuthHandler) getEmailService() (*email.Service, error) {
return email.GetService()
}

View File

@@ -2,6 +2,7 @@ package handler
import ( import (
"carrotskin/internal/container" "carrotskin/internal/container"
"context"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -233,9 +234,12 @@ func (h *CustomSkinHandler) GetTexture(c *gin.Context) {
} }
} }
// 增加下载计数(异步) // 增加下载计数(异步,使用独立 ctx 避免请求取消时丢失计数
go func() { go func() {
_ = h.container.TextureRepo.IncrementDownloadCount(ctx, texture.ID) // 复制 ctx 但不传播取消,配合超时防止 Redis 永久阻塞
asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = h.container.TextureService.IncrementDownload(asyncCtx, texture.ID)
}() }()
// 流式传输文件内容 // 流式传输文件内容

View File

@@ -1,9 +1,10 @@
package handler package handler
import ( import (
"carrotskin/internal/errors" apperrors "carrotskin/internal/errors"
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/internal/types" "carrotskin/internal/types"
"errors"
"net/http" "net/http"
"strconv" "strconv"
@@ -190,31 +191,31 @@ func RespondWithError(c *gin.Context, err error) {
return return
} }
// 使用errors.Is检查预定义错误 // 使用标准库 errors.Is 检查预定义错误
if errors.Is(err, errors.ErrUserNotFound) || if errors.Is(err, apperrors.ErrUserNotFound) ||
errors.Is(err, errors.ErrProfileNotFound) || errors.Is(err, apperrors.ErrProfileNotFound) ||
errors.Is(err, errors.ErrTextureNotFound) || errors.Is(err, apperrors.ErrTextureNotFound) ||
errors.Is(err, errors.ErrNotFound) { errors.Is(err, apperrors.ErrNotFound) {
RespondNotFound(c, err.Error()) RespondNotFound(c, err.Error())
return return
} }
if errors.Is(err, errors.ErrProfileNoPermission) || if errors.Is(err, apperrors.ErrProfileNoPermission) ||
errors.Is(err, errors.ErrTextureNoPermission) || errors.Is(err, apperrors.ErrTextureNoPermission) ||
errors.Is(err, errors.ErrForbidden) { errors.Is(err, apperrors.ErrForbidden) {
RespondForbidden(c, err.Error()) RespondForbidden(c, err.Error())
return return
} }
if errors.Is(err, errors.ErrUnauthorized) || if errors.Is(err, apperrors.ErrUnauthorized) ||
errors.Is(err, errors.ErrInvalidToken) || errors.Is(err, apperrors.ErrInvalidToken) ||
errors.Is(err, errors.ErrTokenExpired) { errors.Is(err, apperrors.ErrTokenExpired) {
RespondUnauthorized(c, err.Error()) RespondUnauthorized(c, err.Error())
return return
} }
// 检查AppError类型 // 检查AppError类型
var appErr *errors.AppError var appErr *apperrors.AppError
if errors.As(err, &appErr) { if errors.As(err, &appErr) {
c.JSON(appErr.Code, model.NewErrorResponse( c.JSON(appErr.Code, model.NewErrorResponse(
appErr.Code, appErr.Code,

View File

@@ -4,7 +4,6 @@ import (
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/middleware" "carrotskin/internal/middleware"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"carrotskin/pkg/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files" swaggerFiles "github.com/swaggo/files"
@@ -40,11 +39,10 @@ func NewHandlers(c *container.Container) *Handlers {
// RegisterRoutesWithDI 使用依赖注入注册所有路由 // RegisterRoutesWithDI 使用依赖注入注册所有路由
func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) { func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 健康检查路由 // 健康检查路由
router.GET("/health", HealthCheck) router.GET("/health", NewHealthCheck(c.DB, c.Redis))
// Swagger文档路由 // Swagger文档路由
cfg, _ := config.GetConfig() if c.Config != nil && c.Config.Server.SwaggerEnabled {
if cfg != nil && cfg.Server.SwaggerEnabled {
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
} }
@@ -52,7 +50,8 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
h := NewHandlers(c) h := NewHandlers(c)
// API路由组 // API路由组
v1 := router.Group("/api/v1") apiGroup := router.Group("/api")
v1 := apiGroup.Group("/v1")
{ {
// 认证路由无需JWT // 认证路由无需JWT
registerAuthRoutes(v1, h.Auth) registerAuthRoutes(v1, h.Auth)
@@ -69,15 +68,16 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 验证码路由 // 验证码路由
registerCaptchaRoutesWithDI(v1, h.Captcha) registerCaptchaRoutesWithDI(v1, h.Captcha)
// Yggdrasil API路由组
registerYggdrasilRoutesWithDI(v1, h.Yggdrasil)
// CustomSkinAPI 路由 // CustomSkinAPI 路由
registerCustomSkinRoutes(v1, h.CustomSkin) registerCustomSkinRoutes(v1, h.CustomSkin)
// 管理员路由(需要管理员权限) // 管理员路由(需要管理员权限)
registerAdminRoutes(v1, c, h.Admin) registerAdminRoutes(v1, c, h.Admin)
} }
// Yggdrasil API路由组独立于v1路径为 /api/yggdrasil
registerYggdrasilRoutesWithDI(apiGroup, h.Yggdrasil)
} }
// registerAuthRoutes 注册认证路由 // registerAuthRoutes 注册认证路由

View File

@@ -6,57 +6,57 @@ import (
"net/http" "net/http"
"time" "time"
"carrotskin/pkg/database"
"carrotskin/pkg/redis" "carrotskin/pkg/redis"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
// HealthCheck 健康检查,检查依赖服务状态 // NewHealthCheck 构造健康检查 handler依赖通过参数注入。
func HealthCheck(c *gin.Context) { func NewHealthCheck(db *gorm.DB, redisClient *redis.Client) gin.HandlerFunc {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) return func(c *gin.Context) {
defer cancel() ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
checks := make(map[string]string) checks := make(map[string]string)
status := "ok" status := "ok"
// 检查数据库 // 检查数据库
if err := checkDatabase(ctx); err != nil { if err := checkDatabase(ctx, db); err != nil {
checks["database"] = "unhealthy: " + err.Error() checks["database"] = "unhealthy: " + err.Error()
status = "degraded" status = "degraded"
} else { } else {
checks["database"] = "healthy" checks["database"] = "healthy"
}
// 检查Redis
if err := checkRedis(ctx, redisClient); err != nil {
checks["redis"] = "unhealthy: " + err.Error()
status = "degraded"
} else {
checks["redis"] = "healthy"
}
// 根据状态返回相应的HTTP状态码
httpStatus := http.StatusOK
if status == "degraded" {
httpStatus = http.StatusServiceUnavailable
}
c.JSON(httpStatus, gin.H{
"status": status,
"message": "CarrotSkin API health check",
"checks": checks,
"timestamp": time.Now().Unix(),
})
} }
// 检查Redis
if err := checkRedis(ctx); err != nil {
checks["redis"] = "unhealthy: " + err.Error()
status = "degraded"
} else {
checks["redis"] = "healthy"
}
// 根据状态返回相应的HTTP状态码
httpStatus := http.StatusOK
if status == "degraded" {
httpStatus = http.StatusServiceUnavailable
}
c.JSON(httpStatus, gin.H{
"status": status,
"message": "CarrotSkin API health check",
"checks": checks,
"timestamp": time.Now().Unix(),
})
} }
// checkDatabase 检查数据库连接 // checkDatabase 检查数据库连接
func checkDatabase(ctx context.Context) error { func checkDatabase(ctx context.Context, db *gorm.DB) error {
db, err := database.GetDB() if db == nil {
if err != nil { return errors.New("数据库未初始化")
return err
} }
sqlDB, err := db.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
return err return err
@@ -77,17 +77,13 @@ func checkDatabase(ctx context.Context) error {
} }
// checkRedis 检查Redis连接 // checkRedis 检查Redis连接
func checkRedis(ctx context.Context) error { func checkRedis(ctx context.Context, client *redis.Client) error {
client, err := redis.GetClient()
if err != nil {
return err
}
if client == nil { if client == nil {
return errors.New("Redis客户端未初始化") return errors.New("Redis客户端未初始化")
} }
// 使用Ping检查连接 // 使用Ping检查连接封装后的方法直接返回error
if err := client.Ping(ctx).Err(); err != nil { if err := client.Ping(ctx); err != nil {
return err return err
} }

View File

@@ -12,7 +12,8 @@ import (
func TestHealthCheck_Degraded(t *testing.T) { func TestHealthCheck_Degraded(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.GET("/health", HealthCheck) // 传入 nil 依赖,模拟服务不可用场景
router.GET("/health", NewHealthCheck(nil, nil))
req := httptest.NewRequest(http.MethodGet, "/health", nil) req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -22,6 +23,3 @@ func TestHealthCheck_Degraded(t *testing.T) {
t.Fatalf("expected 503 when dependencies missing, got %d", w.Code) t.Fatalf("expected 503 when dependencies missing, got %d", w.Code)
} }
} }

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"bytes" "bytes"
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/errors"
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/pkg/utils" "carrotskin/pkg/utils"
"io" "io"
@@ -129,10 +130,11 @@ type (
// RefreshResponse 刷新令牌响应 // RefreshResponse 刷新令牌响应
RefreshResponse struct { RefreshResponse struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"` ClientToken string `json:"clientToken"`
SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"` SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"`
User map[string]interface{} `json:"user,omitempty"` AvailableProfiles []map[string]interface{} `json:"availableProfiles"`
User map[string]interface{} `json:"user,omitempty"`
} }
) )
@@ -175,7 +177,7 @@ func NewYggdrasilHandler(c *container.Container) *YggdrasilHandler {
// @Param request body AuthenticateRequest true "认证请求" // @Param request body AuthenticateRequest true "认证请求"
// @Success 200 {object} AuthenticateResponse // @Success 200 {object} AuthenticateResponse
// @Failure 403 {object} map[string]string "认证失败" // @Failure 403 {object} map[string]string "认证失败"
// @Router /api/v1/yggdrasil/authserver/authenticate [post] // @Router /api/yggdrasil/authserver/authenticate [post]
func (h *YggdrasilHandler) Authenticate(c *gin.Context) { func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
rawData, err := io.ReadAll(c.Request.Body) rawData, err := io.ReadAll(c.Request.Body)
if err != nil { if err != nil {
@@ -199,10 +201,14 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
if emailRegex.MatchString(request.Identifier) { if emailRegex.MatchString(request.Identifier) {
userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier) userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier)
} else { } else {
profile, err = h.container.ProfileRepo.FindByName(c.Request.Context(), request.Identifier) profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier)
if err != nil { if err != nil {
h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err)) h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err))
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidCredentials,
"",
))
return return
} }
userId = profile.UserID userId = profile.UserID
@@ -211,13 +217,21 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
if err != nil { if err != nil {
h.logger.Warn("认证失败: 用户不存在", zap.String("identifier", request.Identifier), zap.Error(err)) h.logger.Warn("认证失败: 用户不存在", zap.String("identifier", request.Identifier), zap.Error(err))
c.JSON(http.StatusForbidden, gin.H{"error": "用户不存在"}) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidCredentials,
"",
))
return return
} }
if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, userId); err != nil { if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, userId); err != nil {
h.logger.Warn("认证失败: 密码错误", zap.Error(err)) h.logger.Warn("认证失败: 密码错误", zap.Error(err))
c.JSON(http.StatusForbidden, gin.H{"error": ErrWrongPassword}) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidCredentials,
"",
))
return return
} }
@@ -264,8 +278,8 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
// @Produce json // @Produce json
// @Param request body ValidTokenRequest true "验证请求" // @Param request body ValidTokenRequest true "验证请求"
// @Success 204 "令牌有效" // @Success 204 "令牌有效"
// @Failure 403 {object} map[string]bool "令牌无效" // @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
// @Router /api/v1/yggdrasil/authserver/validate [post] // @Router /api/yggdrasil/authserver/validate [post]
func (h *YggdrasilHandler) ValidToken(c *gin.Context) { func (h *YggdrasilHandler) ValidToken(c *gin.Context) {
var request ValidTokenRequest var request ValidTokenRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
@@ -276,10 +290,14 @@ func (h *YggdrasilHandler) ValidToken(c *gin.Context) {
if h.container.TokenService.Validate(c.Request.Context(), request.AccessToken, request.ClientToken) { if h.container.TokenService.Validate(c.Request.Context(), request.AccessToken, request.ClientToken) {
h.logger.Info("令牌验证成功", zap.String("accessToken", request.AccessToken)) h.logger.Info("令牌验证成功", zap.String("accessToken", request.AccessToken))
c.JSON(http.StatusNoContent, gin.H{"valid": true}) c.JSON(http.StatusNoContent, gin.H{})
} else { } else {
h.logger.Warn("令牌验证失败", zap.String("accessToken", request.AccessToken)) h.logger.Warn("令牌验证失败", zap.String("accessToken", request.AccessToken))
c.JSON(http.StatusForbidden, gin.H{"valid": false}) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
} }
} }
@@ -292,7 +310,7 @@ func (h *YggdrasilHandler) ValidToken(c *gin.Context) {
// @Param request body RefreshRequest true "刷新请求" // @Param request body RefreshRequest true "刷新请求"
// @Success 200 {object} RefreshResponse // @Success 200 {object} RefreshResponse
// @Failure 400 {object} map[string]string "刷新失败" // @Failure 400 {object} map[string]string "刷新失败"
// @Router /api/v1/yggdrasil/authserver/refresh [post] // @Router /api/yggdrasil/authserver/refresh [post]
func (h *YggdrasilHandler) RefreshToken(c *gin.Context) { func (h *YggdrasilHandler) RefreshToken(c *gin.Context) {
var request RefreshRequest var request RefreshRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
@@ -301,19 +319,33 @@ func (h *YggdrasilHandler) RefreshToken(c *gin.Context) {
return return
} }
UUID, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), request.AccessToken) // 获取用户ID和当前绑定的Profile UUID
userID, err := h.container.TokenService.GetUserIDByAccessToken(c.Request.Context(), request.AccessToken)
if err != nil { if err != nil {
h.logger.Warn("刷新令牌失败: 无效的访问令牌", zap.String("token", request.AccessToken), zap.Error(err)) h.logger.Warn("刷新令牌失败: 无效的访问令牌", zap.String("token", request.AccessToken), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return return
} }
userID, _ := h.container.TokenService.GetUserIDByAccessToken(c.Request.Context(), request.AccessToken) currentUUID, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), request.AccessToken)
UUID = utils.FormatUUID(UUID)
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), UUID)
if err != nil { if err != nil {
h.logger.Error("刷新令牌失败: 无法获取用户信息", zap.Error(err)) h.logger.Warn("刷新令牌失败: 无法获取当前Profile", zap.String("token", request.AccessToken), zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return
}
// 获取用户的所有可用profiles
availableProfiles, err := h.container.ProfileService.GetByUserID(c.Request.Context(), userID)
if err != nil {
h.logger.Error("刷新令牌失败: 无法获取用户profiles", zap.Error(err), zap.Int64("userId", userID))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -322,6 +354,7 @@ func (h *YggdrasilHandler) RefreshToken(c *gin.Context) {
var userData map[string]interface{} var userData map[string]interface{}
var profileID string var profileID string
// 处理selectedProfile
if request.SelectedProfile != nil { if request.SelectedProfile != nil {
profileIDValue, ok := request.SelectedProfile["id"] profileIDValue, ok := request.SelectedProfile["id"]
if !ok { if !ok {
@@ -337,25 +370,69 @@ func (h *YggdrasilHandler) RefreshToken(c *gin.Context) {
return return
} }
profileID = utils.FormatUUID(profileID) // 确保profileID是32位无符号格式用于向后兼容
profileID = utils.FormatUUIDToNoDash(profileID)
// 根据Yggdrasil规范当指定selectedProfile时原令牌所绑定的角色必须为空
// 如果原令牌已绑定角色,则不允许更换角色,但允许选择相同的角色以兼容部分启动器
if currentUUID != "" && currentUUID != profileID {
h.logger.Warn("刷新令牌失败: 令牌已绑定其他角色,不允许更换",
zap.Int64("userId", userID),
zap.String("currentUUID", currentUUID),
zap.String("requestedUUID", profileID),
)
c.JSON(http.StatusBadRequest, errors.NewYggdrasilErrorResponse(
"IllegalArgumentException",
errors.YggErrProfileAlreadyAssigned,
"",
))
return
}
// 验证profile是否属于该用户
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), profileID)
if err != nil {
h.logger.Warn("刷新令牌失败: Profile不存在", zap.String("profileId", profileID), zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return
}
if profile.UserID != userID { if profile.UserID != userID {
h.logger.Warn("刷新令牌失败: 用户不匹配", h.logger.Warn("刷新令牌失败: 用户不匹配",
zap.Int64("userId", userID), zap.Int64("userId", userID),
zap.Int64("profileUserId", profile.UserID), zap.Int64("profileUserId", profile.UserID),
) )
c.JSON(http.StatusBadRequest, gin.H{"error": ErrUserNotMatch}) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return return
} }
profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile) profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)
} else {
// 如果未指定selectedProfile使用当前token绑定的profile如果存在
if currentUUID != "" {
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), currentUUID)
if err == nil && profile != nil {
profileID = currentUUID
profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)
}
}
} }
// 获取用户信息(如果请求)
user, _ := h.container.UserService.GetByID(c.Request.Context(), userID) user, _ := h.container.UserService.GetByID(c.Request.Context(), userID)
if request.RequestUser && user != nil { if request.RequestUser && user != nil {
userData = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, UUID) userData = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, currentUUID)
} }
// 刷新token
newAccessToken, newClientToken, err := h.container.TokenService.Refresh(c.Request.Context(), newAccessToken, newClientToken, err := h.container.TokenService.Refresh(c.Request.Context(),
request.AccessToken, request.AccessToken,
request.ClientToken, request.ClientToken,
@@ -363,16 +440,27 @@ func (h *YggdrasilHandler) RefreshToken(c *gin.Context) {
) )
if err != nil { if err != nil {
h.logger.Error("刷新令牌失败", zap.Error(err), zap.Int64("userId", userID)) h.logger.Error("刷新令牌失败", zap.Error(err), zap.Int64("userId", userID))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return return
} }
// 序列化可用profiles
availableProfilesData := make([]map[string]interface{}, 0, len(availableProfiles))
for _, p := range availableProfiles {
availableProfilesData = append(availableProfilesData, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *p))
}
h.logger.Info("刷新令牌成功", zap.Int64("userId", userID)) h.logger.Info("刷新令牌成功", zap.Int64("userId", userID))
c.JSON(http.StatusOK, RefreshResponse{ c.JSON(http.StatusOK, RefreshResponse{
AccessToken: newAccessToken, AccessToken: newAccessToken,
ClientToken: newClientToken, ClientToken: newClientToken,
SelectedProfile: profileData, SelectedProfile: profileData,
User: userData, AvailableProfiles: availableProfilesData,
User: userData,
}) })
} }
@@ -384,7 +472,7 @@ func (h *YggdrasilHandler) RefreshToken(c *gin.Context) {
// @Produce json // @Produce json
// @Param request body ValidTokenRequest true "失效请求" // @Param request body ValidTokenRequest true "失效请求"
// @Success 204 "操作成功" // @Success 204 "操作成功"
// @Router /api/v1/yggdrasil/authserver/invalidate [post] // @Router /api/yggdrasil/authserver/invalidate [post]
func (h *YggdrasilHandler) InvalidToken(c *gin.Context) { func (h *YggdrasilHandler) InvalidToken(c *gin.Context) {
var request ValidTokenRequest var request ValidTokenRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
@@ -407,7 +495,7 @@ func (h *YggdrasilHandler) InvalidToken(c *gin.Context) {
// @Param request body SignOutRequest true "登出请求" // @Param request body SignOutRequest true "登出请求"
// @Success 204 "操作成功" // @Success 204 "操作成功"
// @Failure 400 {object} map[string]string "参数错误" // @Failure 400 {object} map[string]string "参数错误"
// @Router /api/v1/yggdrasil/authserver/signout [post] // @Router /api/yggdrasil/authserver/signout [post]
func (h *YggdrasilHandler) SignOut(c *gin.Context) { func (h *YggdrasilHandler) SignOut(c *gin.Context) {
var request SignOutRequest var request SignOutRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
@@ -431,7 +519,11 @@ func (h *YggdrasilHandler) SignOut(c *gin.Context) {
if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, user.ID); err != nil { if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, user.ID); err != nil {
h.logger.Warn("登出失败: 密码错误", zap.Int64("userId", user.ID)) h.logger.Warn("登出失败: 密码错误", zap.Int64("userId", user.ID))
c.JSON(http.StatusBadRequest, gin.H{"error": ErrWrongPassword}) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidCredentials,
"",
))
return return
} }
@@ -447,22 +539,35 @@ func (h *YggdrasilHandler) SignOut(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param uuid path string true "用户UUID" // @Param uuid path string true "用户UUID"
// @Param unsigned query string false "是否不返回签名true/false默认false"
// @Success 200 {object} map[string]interface{} "档案信息" // @Success 200 {object} map[string]interface{} "档案信息"
// @Failure 500 {object} APIResponse "服务器错误" // @Failure 404 {object} errors.YggdrasilErrorResponse "档案不存在"
// @Router /api/v1/yggdrasil/sessionserver/session/minecraft/profile/{uuid} [get] // @Router /api/yggdrasil/sessionserver/session/minecraft/profile/{uuid} [get]
func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) { func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) {
uuid := utils.FormatUUID(c.Param("uuid")) uuid := c.Param("uuid")
h.logger.Info("获取配置文件请求", zap.String("uuid", uuid)) h.logger.Info("获取配置文件请求", zap.String("uuid", uuid))
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), uuid) profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), uuid)
if err != nil { if err != nil {
h.logger.Error("获取配置文件失败", zap.Error(err), zap.String("uuid", uuid)) h.logger.Error("获取配置文件失败", zap.Error(err), zap.String("uuid", uuid))
standardResponse(c, http.StatusInternalServerError, nil, err.Error()) c.JSON(http.StatusNotFound, errors.NewYggdrasilErrorResponse(
"Not Found",
"Profile not found",
"",
))
return return
} }
h.logger.Info("成功获取配置文件", zap.String("uuid", uuid), zap.String("name", profile.Name)) // 读取 unsigned 查询参数
c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)) unsignedParam := c.DefaultQuery("unsigned", "false")
unsigned := unsignedParam == "true"
h.logger.Info("成功获取配置文件",
zap.String("uuid", uuid),
zap.String("name", profile.Name),
zap.Bool("unsigned", unsigned))
c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfileWithUnsigned(c.Request.Context(), *profile, unsigned))
} }
// JoinServer 加入服务器 // JoinServer 加入服务器
@@ -475,14 +580,18 @@ func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) {
// @Success 204 "加入成功" // @Success 204 "加入成功"
// @Failure 400 {object} APIResponse "参数错误" // @Failure 400 {object} APIResponse "参数错误"
// @Failure 500 {object} APIResponse "服务器错误" // @Failure 500 {object} APIResponse "服务器错误"
// @Router /api/v1/yggdrasil/sessionserver/session/minecraft/join [post] // @Router /api/yggdrasil/sessionserver/session/minecraft/join [post]
func (h *YggdrasilHandler) JoinServer(c *gin.Context) { func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
var request JoinServerRequest var request JoinServerRequest
clientIP := c.ClientIP() clientIP := c.ClientIP()
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
h.logger.Error("解析加入服务器请求失败", zap.Error(err), zap.String("ip", clientIP)) h.logger.Error("解析加入服务器请求失败", zap.Error(err), zap.String("ip", clientIP))
standardResponse(c, http.StatusBadRequest, nil, ErrInvalidRequest) c.JSON(http.StatusBadRequest, errors.NewYggdrasilErrorResponse(
"Bad Request",
"Invalid request format",
"",
))
return return
} }
@@ -499,7 +608,11 @@ func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
zap.String("userUUID", request.SelectedProfile), zap.String("userUUID", request.SelectedProfile),
zap.String("ip", clientIP), zap.String("ip", clientIP),
) )
standardResponse(c, http.StatusInternalServerError, nil, ErrJoinServerFailed) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return return
} }
@@ -522,7 +635,7 @@ func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
// @Param ip query string false "客户端IP" // @Param ip query string false "客户端IP"
// @Success 200 {object} map[string]interface{} "验证成功,返回档案" // @Success 200 {object} map[string]interface{} "验证成功,返回档案"
// @Failure 204 "验证失败" // @Failure 204 "验证失败"
// @Router /api/v1/yggdrasil/sessionserver/session/minecraft/hasJoined [get] // @Router /api/yggdrasil/sessionserver/session/minecraft/hasJoined [get]
func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) { func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
clientIP, _ := c.GetQuery("ip") clientIP, _ := c.GetQuery("ip")
@@ -557,7 +670,7 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
return return
} }
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), username) profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username)
if err != nil { if err != nil {
h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username)) h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username))
standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound) standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound)
@@ -581,7 +694,7 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
// @Param request body []string true "用户名列表" // @Param request body []string true "用户名列表"
// @Success 200 {array} model.Profile "档案列表" // @Success 200 {array} model.Profile "档案列表"
// @Failure 400 {object} APIResponse "参数错误" // @Failure 400 {object} APIResponse "参数错误"
// @Router /api/v1/yggdrasil/api/profiles/minecraft [post] // @Router /api/yggdrasil/api/profiles/minecraft [post]
func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) { func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
var names []string var names []string
@@ -610,7 +723,7 @@ func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
// @Produce json // @Produce json
// @Success 200 {object} map[string]interface{} "元数据" // @Success 200 {object} map[string]interface{} "元数据"
// @Failure 500 {object} APIResponse "服务器错误" // @Failure 500 {object} APIResponse "服务器错误"
// @Router /api/v1/yggdrasil [get] // @Router /api/yggdrasil [get]
func (h *YggdrasilHandler) GetMetaData(c *gin.Context) { func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
meta := gin.H{ meta := gin.H{
"implementationName": "CellAuth", "implementationName": "CellAuth",
@@ -628,7 +741,11 @@ func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
signature, err := h.container.YggdrasilService.GetPublicKey(c.Request.Context()) signature, err := h.container.YggdrasilService.GetPublicKey(c.Request.Context())
if err != nil { if err != nil {
h.logger.Error("获取公钥失败", zap.Error(err)) h.logger.Error("获取公钥失败", zap.Error(err))
standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer) c.JSON(http.StatusInternalServerError, errors.NewYggdrasilErrorResponse(
"Internal Server Error",
"Failed to get public key",
"",
))
return return
} }
@@ -648,27 +765,40 @@ func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
// @Produce json // @Produce json
// @Param Authorization header string true "Bearer {token}" // @Param Authorization header string true "Bearer {token}"
// @Success 200 {object} map[string]interface{} "证书信息" // @Success 200 {object} map[string]interface{} "证书信息"
// @Failure 401 {object} map[string]string "未授权" // @Failure 401 {object} errors.YggdrasilErrorResponse "未授权"
// @Failure 500 {object} APIResponse "服务器错误" // @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
// @Router /api/v1/yggdrasil/minecraftservices/player/certificates [post] // @Failure 500 {object} errors.YggdrasilErrorResponse "服务器错误"
// @Router /api/yggdrasil/minecraftservices/player/certificates [post]
func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) { func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
if authHeader == "" { if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header not provided"}) c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
"Unauthorized",
"Authorization header not provided",
"",
))
c.Abort() c.Abort()
return return
} }
bearerPrefix := "Bearer " bearerPrefix := "Bearer "
if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix { if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"}) c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
"Unauthorized",
"Invalid Authorization format",
"",
))
c.Abort() c.Abort()
return return
} }
tokenID := authHeader[len(bearerPrefix):] tokenID := authHeader[len(bearerPrefix):]
if tokenID == "" { if tokenID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"}) c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
"Unauthorized",
"Invalid Authorization format",
"",
))
c.Abort() c.Abort()
return return
} }
@@ -676,16 +806,24 @@ func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) {
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID) uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID)
if uuid == "" { if uuid == "" {
h.logger.Error("获取玩家UUID失败", zap.Error(err)) h.logger.Error("获取玩家UUID失败", zap.Error(err))
standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer) c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return return
} }
uuid = utils.FormatUUID(uuid) // UUID已经是32位无符号格式无需转换
certificate, err := h.container.YggdrasilService.GeneratePlayerCertificate(c.Request.Context(), uuid) certificate, err := h.container.YggdrasilService.GeneratePlayerCertificate(c.Request.Context(), uuid)
if err != nil { if err != nil {
h.logger.Error("生成玩家证书失败", zap.Error(err)) h.logger.Error("生成玩家证书失败", zap.Error(err))
standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer) c.JSON(http.StatusInternalServerError, errors.NewYggdrasilErrorResponse(
"Internal Server Error",
"Failed to generate player certificate",
"",
))
return return
} }

View File

@@ -3,6 +3,7 @@ package middleware
import ( import (
"net/http" "net/http"
"carrotskin/internal/model"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -15,10 +16,11 @@ func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string
// 从上下文获取用户角色由AuthMiddleware设置 // 从上下文获取用户角色由AuthMiddleware设置
role, exists := c.Get("user_role") role, exists := c.Get("user_role")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
"success": false, model.CodeUnauthorized,
"message": "未授权访问", model.MsgUnauthorized,
}) nil,
))
c.Abort() c.Abort()
return return
} }
@@ -30,10 +32,11 @@ func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string
// 检查权限 // 检查权限
if !casbinService.CheckPermission(roleStr, resource, action) { if !casbinService.CheckPermission(roleStr, resource, action) {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, model.NewErrorResponse(
"success": false, model.CodeForbidden,
"message": "权限不足", model.MsgForbidden,
}) nil,
))
c.Abort() c.Abort()
return return
} }
@@ -47,20 +50,22 @@ func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, exists := c.Get("user_role") role, exists := c.Get("user_role")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
"success": false, model.CodeUnauthorized,
"message": "未授权访问", model.MsgUnauthorized,
}) nil,
))
c.Abort() c.Abort()
return return
} }
roleStr, ok := role.(string) roleStr, ok := role.(string)
if !ok || roleStr != "admin" { if !ok || roleStr != "admin" {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, model.NewErrorResponse(
"success": false, model.CodeForbidden,
"message": "需要管理员权限", "需要管理员权限",
}) nil,
))
c.Abort() c.Abort()
return return
} }
@@ -74,20 +79,22 @@ func RequireRole(allowedRoles ...string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, exists := c.Get("user_role") role, exists := c.Get("user_role")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
"success": false, model.CodeUnauthorized,
"message": "未授权访问", model.MsgUnauthorized,
}) nil,
))
c.Abort() c.Abort()
return return
} }
roleStr, ok := role.(string) roleStr, ok := role.(string)
if !ok { if !ok {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, model.NewErrorResponse(
"success": false, model.CodeForbidden,
"message": "权限不足", model.MsgForbidden,
}) nil,
))
c.Abort() c.Abort()
return return
} }
@@ -100,10 +107,11 @@ func RequireRole(allowedRoles ...string) gin.HandlerFunc {
} }
} }
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, model.NewErrorResponse(
"success": false, model.CodeForbidden,
"message": "权限不足", model.MsgForbidden,
}) nil,
))
c.Abort() c.Abort()
} }
} }

View File

@@ -7,18 +7,14 @@ import (
) )
// CORS 跨域中间件 // CORS 跨域中间件
func CORS() gin.HandlerFunc { func CORS(cfg *config.Config) gin.HandlerFunc {
// 获取配置,如果配置未初始化则使用默认值 // 从注入的配置读取 CORS 设置
var allowedOrigins []string allowedOrigins := cfg.Security.AllowedOrigins
var isTestEnv bool if len(allowedOrigins) == 0 {
if cfg, err := config.GetConfig(); err == nil { // 默认允许所有来源
allowedOrigins = cfg.Security.AllowedOrigins
isTestEnv = cfg.IsTestEnvironment()
} else {
// 默认允许所有来源(向后兼容)
allowedOrigins = []string{"*"} allowedOrigins = []string{"*"}
isTestEnv = false
} }
isTestEnv := cfg.IsTestEnvironment()
return gin.HandlerFunc(func(c *gin.Context) { return gin.HandlerFunc(func(c *gin.Context) {
origin := c.GetHeader("Origin") origin := c.GetHeader("Origin")

View File

@@ -5,15 +5,26 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"carrotskin/pkg/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// testCORSConfig 返回测试用的 CORS 配置(通配符模式)
func testCORSConfig() *config.Config {
return &config.Config{
Security: config.SecurityConfig{
AllowedOrigins: []string{"*"},
},
}
}
// TestCORS_Headers 测试CORS中间件设置的响应头 // TestCORS_Headers 测试CORS中间件设置的响应头
func TestCORS_Headers(t *testing.T) { func TestCORS_Headers(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })
@@ -55,7 +66,7 @@ func TestCORS_OPTIONS(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })
@@ -76,7 +87,7 @@ func TestCORS_AllowMethods(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })
@@ -103,7 +114,7 @@ func TestCORS_AllowHeaders(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })
@@ -130,7 +141,7 @@ func TestCORS_WithSpecificOrigin(t *testing.T) {
// 注意此测试验证的是在配置了具体allowed origins时的行为 // 注意此测试验证的是在配置了具体allowed origins时的行为
// 在没有配置初始化的情况下,默认使用通配符模式 // 在没有配置初始化的情况下,默认使用通配符模式
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })

View File

@@ -5,10 +5,10 @@ import "time"
// Client 客户端实体用于管理Token版本 // Client 客户端实体用于管理Token版本
// @Description Yggdrasil客户端Token管理数据 // @Description Yggdrasil客户端Token管理数据
type Client struct { type Client struct {
UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"` // Client UUID UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"` // Client UUID
ClientToken string `gorm:"column:client_token;type:varchar(64);not null;uniqueIndex" json:"client_token"` // 客户端Token ClientToken string `gorm:"column:client_token;type:varchar(64);not null;uniqueIndex" json:"client_token"` // 客户端Token
UserID int64 `gorm:"column:user_id;not null;index:idx_clients_user_id" json:"user_id"` // 用户ID UserID int64 `gorm:"column:user_id;not null;index:idx_clients_user_id" json:"user_id"` // 用户ID
ProfileID string `gorm:"column:profile_id;type:varchar(36);index:idx_clients_profile_id" json:"profile_id,omitempty"` // 选中的Profile ProfileID string `gorm:"column:profile_id;type:varchar(32);index:idx_clients_profile_id" json:"profile_id,omitempty"` // 选中的Profile
Version int `gorm:"column:version;not null;default:0;index:idx_clients_version" json:"version"` // 版本号 Version int `gorm:"column:version;not null;default:0;index:idx_clients_version" json:"version"` // 版本号
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"` CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`

View File

@@ -7,7 +7,7 @@ import (
// Profile Minecraft 档案模型 // Profile Minecraft 档案模型
// @Description Minecraft角色档案数据模型 // @Description 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(32);primaryKey" json:"uuid"`
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,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"`

View File

@@ -12,6 +12,7 @@ type UserRepository interface {
FindByUsername(ctx context.Context, username string) (*model.User, error) FindByUsername(ctx context.Context, username string) (*model.User, error)
FindByEmail(ctx context.Context, email string) (*model.User, error) FindByEmail(ctx context.Context, email string) (*model.User, error)
FindByIDs(ctx context.Context, ids []int64) ([]*model.User, error) // 批量查询 FindByIDs(ctx context.Context, ids []int64) ([]*model.User, error) // 批量查询
List(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) // 分页列表(管理员)
Update(ctx context.Context, user *model.User) error Update(ctx context.Context, user *model.User) error
UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error
BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) // 批量更新 BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) // 批量更新
@@ -64,6 +65,8 @@ type TextureRepository interface {
RemoveFavorite(ctx context.Context, userID, textureID int64) error RemoveFavorite(ctx context.Context, userID, textureID int64) error
GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error)
CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error) CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error)
ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) // 管理员列表(含 Uploader 预加载)
FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) // 管理员查询(无权限过滤)
} }
@@ -71,6 +74,8 @@ type TextureRepository interface {
type YggdrasilRepository interface { type YggdrasilRepository interface {
GetPasswordByID(ctx context.Context, id int64) (string, error) GetPasswordByID(ctx context.Context, id int64) (string, error)
ResetPassword(ctx context.Context, id int64, password string) error ResetPassword(ctx context.Context, id int64, password string) error
// Create 创建Yggdrasil密码记录用于首次设置密码
Create(ctx context.Context, yggdrasil *model.Yggdrasil) error
} }
// ClientRepository Client仓储接口 // ClientRepository Client仓储接口

View File

@@ -131,8 +131,8 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (*
var profile model.Profile var profile model.Profile
result := r.db.WithContext(ctx). result := r.db.WithContext(ctx).
Select("key_pair"). Select("rsa_private_key").
Where("id = ?", profileId). Where("uuid = ?", profileId).
First(&profile) First(&profile)
if result.Error != nil { if result.Error != nil {
@@ -142,7 +142,9 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (*
return nil, fmt.Errorf("获取key pair失败: %w", result.Error) return nil, fmt.Errorf("获取key pair失败: %w", result.Error)
} }
return &model.KeyPair{}, nil return &model.KeyPair{
PrivateKey: profile.RSAPrivateKey,
}, nil
} }
func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error { func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error {
@@ -154,12 +156,9 @@ func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string,
} }
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Table("profiles"). result := tx.Model(&model.Profile{}).
Where("id = ?", profileId). Where("uuid = ?", profileId).
UpdateColumns(map[string]interface{}{ Update("rsa_private_key", keyPair.PrivateKey)
"private_key": keyPair.PrivateKey,
"public_key": keyPair.PublicKey,
})
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("更新 keyPair 失败: %w", result.Error) return fmt.Errorf("更新 keyPair 失败: %w", result.Error)

View File

@@ -210,3 +210,27 @@ func (r *textureRepository) CountByUploaderID(ctx context.Context, uploaderID in
Count(&count).Error Count(&count).Error
return count, err return count, err
} }
// ListForAdmin 管理员分页查询材质列表(含 Uploader 预加载,无权限过滤)
func (r *textureRepository) ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) {
var textures []*model.Texture
var total int64
db := r.db.WithContext(ctx).Model(&model.Texture{})
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := db.Preload("Uploader").Order("id DESC").Offset(offset).Limit(pageSize).Find(&textures).Error; err != nil {
return nil, 0, err
}
return textures, total, nil
}
// FindByIDForAdmin 管理员查询材质(无权限过滤,用于管理员删除前检查存在性)
func (r *textureRepository) FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) {
var texture model.Texture
err := r.db.WithContext(ctx).Where("id = ?", id).First(&texture).Error
return handleNotFoundResult(&texture, err)
}

View File

@@ -50,6 +50,23 @@ func (r *userRepository) FindByIDs(ctx context.Context, ids []int64) ([]*model.U
return users, err return users, err
} }
// List 分页查询用户列表(管理员,不过滤状态)
func (r *userRepository) List(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) {
var users []*model.User
var total int64
db := r.db.WithContext(ctx).Model(&model.User{})
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := db.Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
func (r *userRepository) Update(ctx context.Context, user *model.User) error { func (r *userRepository) Update(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Save(user).Error return r.db.WithContext(ctx).Save(user).Error
} }

View File

@@ -30,6 +30,11 @@ func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, passw
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
} }
// Create 创建Yggdrasil密码记录
func (r *yggdrasilRepository) Create(ctx context.Context, yggdrasil *model.Yggdrasil) error {
return r.db.WithContext(ctx).Create(yggdrasil).Error
}

View File

@@ -3,13 +3,13 @@ package service
import ( import (
"carrotskin/pkg/config" "carrotskin/pkg/config"
"carrotskin/pkg/redis" "carrotskin/pkg/redis"
"carrotskin/pkg/utils"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log" "log"
"time" "time"
"github.com/google/uuid"
"github.com/wenlng/go-captcha-assets/resources/imagesv2" "github.com/wenlng/go-captcha-assets/resources/imagesv2"
"github.com/wenlng/go-captcha-assets/resources/tiles" "github.com/wenlng/go-captcha-assets/resources/tiles"
"github.com/wenlng/go-captcha/v2/slide" "github.com/wenlng/go-captcha/v2/slide"
@@ -72,13 +72,15 @@ type RedisData struct {
// captchaService CaptchaService的实现 // captchaService CaptchaService的实现
type captchaService struct { type captchaService struct {
cfg *config.Config
redis *redis.Client redis *redis.Client
logger *zap.Logger logger *zap.Logger
} }
// NewCaptchaService 创建CaptchaService实例 // NewCaptchaService 创建CaptchaService实例
func NewCaptchaService(redisClient *redis.Client, logger *zap.Logger) CaptchaService { func NewCaptchaService(cfg *config.Config, redisClient *redis.Client, logger *zap.Logger) CaptchaService {
return &captchaService{ return &captchaService{
cfg: cfg,
redis: redisClient, redis: redisClient,
logger: logger, logger: logger,
} }
@@ -87,7 +89,7 @@ func NewCaptchaService(redisClient *redis.Client, logger *zap.Logger) CaptchaSer
// Generate 生成验证码 // Generate 生成验证码
func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error) { func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error) {
// 生成uuid作为验证码进程唯一标识 // 生成uuid作为验证码进程唯一标识
captchaID = uuid.NewString() captchaID = utils.GenerateUUID()
if captchaID == "" { if captchaID == "" {
err = errors.New("生成验证码唯一标识失败") err = errors.New("生成验证码唯一标识失败")
return return
@@ -157,8 +159,7 @@ func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, capt
// Verify 验证验证码 // Verify 验证验证码
func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (bool, error) { func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (bool, error) {
// 测试环境下直接通过验证 // 测试环境下直接通过验证
cfg, err := config.GetConfig() if s.cfg.IsTestEnvironment() {
if err == nil && cfg.IsTestEnvironment() {
return true, nil return true, nil
} }
@@ -180,12 +181,48 @@ func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (
ty := redisData.Ty ty := redisData.Ty
ok := slide.Validate(dx, ty, tx, ty, paddingValue) ok := slide.Validate(dx, ty, tx, ty, paddingValue)
// 验证后立即删除Redis记录防止重复使用 // 验证成功后标记为已验证状态设置5分钟有效期
if ok { if ok {
verifiedKey := redisKeyPrefix + "verified:" + captchaID
if err := s.redis.Set(ctx, verifiedKey, "1", 5*time.Minute); err != nil {
s.logger.Warn("设置验证码已验证标记失败", zap.Error(err))
}
// 删除原始验证码记录(防止重复验证)
if err := s.redis.Del(ctx, redisKey); err != nil { if err := s.redis.Del(ctx, redisKey); err != nil {
// 记录警告但不影响验证结果
s.logger.Warn("删除验证码Redis记录失败", zap.Error(err)) s.logger.Warn("删除验证码Redis记录失败", zap.Error(err))
} }
} }
return ok, nil return ok, nil
} }
// CheckVerified 检查验证码是否已验证仅检查captcha_id
func (s *captchaService) CheckVerified(ctx context.Context, captchaID string) (bool, error) {
// 测试环境下直接通过验证
if s.cfg.IsTestEnvironment() {
return true, nil
}
verifiedKey := redisKeyPrefix + "verified:" + captchaID
exists, err := s.redis.Exists(ctx, verifiedKey)
if err != nil {
return false, fmt.Errorf("检查验证状态失败: %w", err)
}
if exists == 0 {
return false, errors.New("验证码未验证或已过期")
}
return true, nil
}
// ConsumeVerified 消耗已验证的验证码(注册成功后调用)
func (s *captchaService) ConsumeVerified(ctx context.Context, captchaID string) error {
// 测试环境下直接返回成功
if s.cfg.IsTestEnvironment() {
return nil
}
verifiedKey := redisKeyPrefix + "verified:" + captchaID
if err := s.redis.Del(ctx, verifiedKey); err != nil {
s.logger.Warn("删除验证码已验证标记失败", zap.Error(err))
}
return nil
}

View File

@@ -1,18 +1,11 @@
package service package service
import ( // 通用辅助函数。
"errors" //
"fmt" // 注意:错误定义已统一到 internal/errors 包。
) // 原先在此文件重复定义的 ErrProfileNotFound/ErrProfileNoPermission/
// ErrTextureNotFound/ErrTextureNoPermission/ErrUserNotFound 已删除,
// 通用错误 // 请使用 internal/errors别名 apperrors中的对应定义。
var (
ErrProfileNotFound = errors.New("档案不存在")
ErrProfileNoPermission = errors.New("无权操作此档案")
ErrTextureNotFound = errors.New("材质不存在")
ErrTextureNoPermission = errors.New("无权操作此材质")
ErrUserNotFound = errors.New("用户不存在")
)
// NormalizePagination 规范化分页参数 // NormalizePagination 规范化分页参数
func NormalizePagination(page, pageSize int) (int, int) { func NormalizePagination(page, pageSize int) (int, int) {
@@ -27,11 +20,3 @@ func NormalizePagination(page, pageSize int) (int, int) {
} }
return page, pageSize return page, pageSize
} }
// WrapError 包装错误,添加上下文信息
func WrapError(err error, message string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", message, err)
}

View File

@@ -1,7 +1,6 @@
package service package service
import ( import (
"errors"
"testing" "testing"
) )
@@ -30,21 +29,3 @@ func TestNormalizePagination_Basic(t *testing.T) {
}) })
} }
} }
// TestWrapError 覆盖 WrapError 的 nil 与非 nil 分支
func TestWrapError(t *testing.T) {
if err := WrapError(nil, "msg"); err != nil {
t.Fatalf("WrapError(nil, ...) 应返回 nil, got=%v", err)
}
orig := errors.New("orig")
wrapped := WrapError(orig, "context")
if wrapped == nil {
t.Fatalf("WrapError 应返回非 nil 错误")
}
if wrapped.Error() == orig.Error() {
t.Fatalf("WrapError 应添加上下文信息, got=%v", wrapped)
}
}

View File

@@ -37,6 +37,11 @@ type UserService interface {
// 配置获取 // 配置获取
GetMaxProfilesPerUser() int GetMaxProfilesPerUser() int
GetMaxTexturesPerUser() int GetMaxTexturesPerUser() int
// 管理员操作
ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, int64, error)
SetRole(ctx context.Context, userID int64, role string) error
SetStatus(ctx context.Context, userID int64, status int16) error
} }
// ProfileService 档案服务接口 // ProfileService 档案服务接口
@@ -73,12 +78,18 @@ type TextureService interface {
// 限制检查 // 限制检查
CheckUploadLimit(ctx context.Context, uploaderID int64, maxTextures int) error CheckUploadLimit(ctx context.Context, uploaderID int64, maxTextures int) error
// 管理员操作
ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error)
AdminDelete(ctx context.Context, textureID int64) error
IncrementDownload(ctx context.Context, textureID int64) error
} }
// TokenService 令牌服务接口 // TokenService 令牌服务接口
type TokenService interface { type TokenService interface {
// 令牌管理 // 令牌管理
Create(ctx context.Context, userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error) Create(ctx context.Context, userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error)
CreateWithProfile(ctx context.Context, userID int64, profileUUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error)
Validate(ctx context.Context, accessToken, clientToken string) bool Validate(ctx context.Context, accessToken, clientToken string) bool
Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error)
Invalidate(ctx context.Context, accessToken string) Invalidate(ctx context.Context, accessToken string)
@@ -99,6 +110,8 @@ type VerificationService interface {
type CaptchaService interface { type CaptchaService interface {
Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error) Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error)
Verify(ctx context.Context, dx int, captchaID string) (bool, error) Verify(ctx context.Context, dx int, captchaID string) (bool, error)
CheckVerified(ctx context.Context, captchaID string) (bool, error)
ConsumeVerified(ctx context.Context, captchaID string) error
} }
// YggdrasilService Yggdrasil服务接口 // YggdrasilService Yggdrasil服务接口
@@ -116,6 +129,7 @@ type YggdrasilService interface {
// 序列化 // 序列化
SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{}
SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{}
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
// 证书 // 证书

View File

@@ -130,6 +130,24 @@ func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []int64) ([]*mod
return result, nil return result, nil
} }
// List 分页查询用户列表管理员mock 实现)
func (m *MockUserRepository) List(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) {
var all []*model.User
for _, u := range m.users {
all = append(all, u)
}
total := int64(len(all))
offset := (page - 1) * pageSize
if offset >= len(all) {
return []*model.User{}, total, nil
}
end := offset + pageSize
if end > len(all) {
end = len(all)
}
return all[offset:end], total, nil
}
// MockProfileRepository 模拟ProfileRepository // MockProfileRepository 模拟ProfileRepository
type MockProfileRepository struct { type MockProfileRepository struct {
profiles map[string]*model.Profile profiles map[string]*model.Profile
@@ -474,6 +492,32 @@ func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (i
return deleted, nil return deleted, nil
} }
// ListForAdmin 管理员列表mock 实现)
func (m *MockTextureRepository) ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) {
var all []*model.Texture
for _, t := range m.textures {
all = append(all, t)
}
total := int64(len(all))
offset := (page - 1) * pageSize
if offset >= len(all) {
return []*model.Texture{}, total, nil
}
end := offset + pageSize
if end > len(all) {
end = len(all)
}
return all[offset:end], total, nil
}
// FindByIDForAdmin 管理员查询mock 实现,无权限过滤)
func (m *MockTextureRepository) FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) {
if t, ok := m.textures[id]; ok {
return t, nil
}
return nil, nil
}
// ============================================================================ // ============================================================================
// Service Mocks // Service Mocks

View File

@@ -1,9 +1,11 @@
package service package service
import ( import (
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/internal/repository" "carrotskin/internal/repository"
"carrotskin/pkg/database" "carrotskin/pkg/database"
"carrotskin/pkg/utils"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
@@ -12,7 +14,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -64,7 +65,7 @@ func (s *profileService) Create(ctx context.Context, userID int64, name string)
} }
// 生成UUID和RSA密钥 // 生成UUID和RSA密钥
profileUUID := uuid.New().String() profileUUID := utils.GenerateUUID()
privateKey, err := generateRSAPrivateKeyInternal() privateKey, err := generateRSAPrivateKeyInternal()
if err != nil { if err != nil {
return nil, fmt.Errorf("生成RSA密钥失败: %w", err) return nil, fmt.Errorf("生成RSA密钥失败: %w", err)
@@ -100,7 +101,7 @@ func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Pro
profile2, err := s.profileRepo.FindByUUID(ctx, uuid) profile2, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProfileNotFound return nil, apperrors.ErrProfileNotFound
} }
return nil, fmt.Errorf("查询档案失败: %w", err) return nil, fmt.Errorf("查询档案失败: %w", err)
} }
@@ -140,13 +141,13 @@ func (s *profileService) Update(ctx context.Context, uuid string, userID int64,
profile, err := s.profileRepo.FindByUUID(ctx, uuid) profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProfileNotFound return nil, apperrors.ErrProfileNotFound
} }
return nil, fmt.Errorf("查询档案失败: %w", err) return nil, fmt.Errorf("查询档案失败: %w", err)
} }
if profile.UserID != userID { if profile.UserID != userID {
return nil, ErrProfileNoPermission return nil, apperrors.ErrProfileNoPermission
} }
// 检查角色名是否重复 // 检查角色名是否重复
@@ -187,13 +188,13 @@ func (s *profileService) Delete(ctx context.Context, uuid string, userID int64)
profile, err := s.profileRepo.FindByUUID(ctx, uuid) profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrProfileNotFound return apperrors.ErrProfileNotFound
} }
return fmt.Errorf("查询档案失败: %w", err) return fmt.Errorf("查询档案失败: %w", err)
} }
if profile.UserID != userID { if profile.UserID != userID {
return ErrProfileNoPermission return apperrors.ErrProfileNoPermission
} }
if err := s.profileRepo.Delete(ctx, uuid); err != nil { if err := s.profileRepo.Delete(ctx, uuid); err != nil {

View File

@@ -53,7 +53,7 @@ func NewSignatureService(
} }
// NewKeyPair 生成新的RSA密钥对 // NewKeyPair 生成新的RSA密钥对
func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) { func (s *SignatureService) NewKeyPair(ctx context.Context) (*model.KeyPair, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, KeySize) privateKey, err := rsa.GenerateKey(rand.Reader, KeySize)
if err != nil { if err != nil {
return nil, fmt.Errorf("生成RSA密钥对失败: %w", err) return nil, fmt.Errorf("生成RSA密钥对失败: %w", err)
@@ -85,7 +85,7 @@ func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) {
refresh := now.AddDate(0, 0, RefreshDays) refresh := now.AddDate(0, 0, RefreshDays)
// 获取Yggdrasil根密钥并签名公钥 // 获取Yggdrasil根密钥并签名公钥
yggPublicKey, yggPrivateKey, err := s.GetOrCreateYggdrasilKeyPair() yggPublicKey, yggPrivateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err) return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err)
} }
@@ -132,9 +132,7 @@ func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) {
} }
// GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对 // GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对
func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKey, error) { func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) {
ctx := context.Background()
// 尝试从Redis获取密钥 // 尝试从Redis获取密钥
publicKeyPEM, err := s.redis.Get(ctx, PublicKeyRedisKey) publicKeyPEM, err := s.redis.Get(ctx, PublicKeyRedisKey)
if err == nil && publicKeyPEM != "" { if err == nil && publicKeyPEM != "" {
@@ -201,15 +199,14 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKe
} }
// GetPublicKeyFromRedis 从Redis获取公钥 // GetPublicKeyFromRedis 从Redis获取公钥
func (s *SignatureService) GetPublicKeyFromRedis() (string, error) { func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, error) {
ctx := context.Background()
publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey) publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey)
if err != nil { if err != nil {
return "", fmt.Errorf("从Redis获取公钥失败: %w", err) return "", fmt.Errorf("从Redis获取公钥失败: %w", err)
} }
if publicKey == "" { if publicKey == "" {
// 如果Redis中没有创建新的密钥对 // 如果Redis中没有创建新的密钥对
publicKey, _, err = s.GetOrCreateYggdrasilKeyPair() publicKey, _, err = s.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil { if err != nil {
return "", fmt.Errorf("创建新密钥对失败: %w", err) return "", fmt.Errorf("创建新密钥对失败: %w", err)
} }
@@ -218,14 +215,13 @@ func (s *SignatureService) GetPublicKeyFromRedis() (string, error) {
} }
// SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串 // SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串
func (s *SignatureService) SignStringWithSHA1withRSA(data string) (string, error) { func (s *SignatureService) SignStringWithSHA1withRSA(ctx context.Context, data string) (string, error) {
ctx := context.Background()
// 从Redis获取私钥 // 从Redis获取私钥
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey) privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
if err != nil || privateKeyPEM == "" { if err != nil || privateKeyPEM == "" {
// 如果没有私钥,创建新的密钥对 // 如果没有私钥,创建新的密钥对
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair() _, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil { if err != nil {
return "", fmt.Errorf("获取私钥失败: %w", err) return "", fmt.Errorf("获取私钥失败: %w", err)
} }
@@ -274,3 +270,26 @@ func FormatPublicKey(publicKeyPEM string) string {
} }
return strings.Join(keyLines, "") return strings.Join(keyLines, "")
} }
// SignStringWithProfileRSA 使用Profile的RSA私钥签名字符串
func (s *SignatureService) SignStringWithProfileRSA(data string, privateKeyPEM string) (string, error) {
// 解析PEM格式的私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("解析PEM私钥失败")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析RSA私钥失败: %w", err)
}
// 签名
hashed := sha1.Sum([]byte(data))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil {
return "", fmt.Errorf("签名失败: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}

View File

@@ -2,6 +2,7 @@ package service
import ( import (
"bytes" "bytes"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/internal/repository" "carrotskin/internal/repository"
"carrotskin/pkg/database" "carrotskin/pkg/database"
@@ -13,8 +14,10 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
) )
// textureService TextureService的实现 // textureService TextureService的实现
@@ -26,6 +29,7 @@ type textureService struct {
cacheKeys *database.CacheKeyBuilder cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator cacheInv *database.CacheInvalidator
logger *zap.Logger logger *zap.Logger
db *gorm.DB
} }
// NewTextureService 创建TextureService实例 // NewTextureService 创建TextureService实例
@@ -35,6 +39,7 @@ func NewTextureService(
storageClient *storage.StorageClient, storageClient *storage.StorageClient,
cacheManager *database.CacheManager, cacheManager *database.CacheManager,
logger *zap.Logger, logger *zap.Logger,
db *gorm.DB,
) TextureService { ) TextureService {
return &textureService{ return &textureService{
textureRepo: textureRepo, textureRepo: textureRepo,
@@ -44,6 +49,7 @@ func NewTextureService(
cacheKeys: database.NewCacheKeyBuilder(""), cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager), cacheInv: database.NewCacheInvalidator(cacheManager),
logger: logger, logger: logger,
db: db,
} }
} }
@@ -62,7 +68,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
return nil, err return nil, err
} }
if texture2 == nil { if texture2 == nil {
return nil, ErrTextureNotFound return nil, apperrors.ErrTextureNotFound
} }
if texture2.Status == -1 { if texture2.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
@@ -80,7 +86,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
return nil, err return nil, err
} }
if texture2 == nil { if texture2 == nil {
return nil, ErrTextureNotFound return nil, apperrors.ErrTextureNotFound
} }
if texture2.Status == -1 { if texture2.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
@@ -109,7 +115,7 @@ func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Tex
return nil, err return nil, err
} }
if texture2 == nil { if texture2 == nil {
return nil, ErrTextureNotFound return nil, apperrors.ErrTextureNotFound
} }
if texture2.Status == -1 { if texture2.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
@@ -162,10 +168,10 @@ func (s *textureService) Update(ctx context.Context, textureID, uploaderID int64
return nil, err return nil, err
} }
if texture == nil { if texture == nil {
return nil, ErrTextureNotFound return nil, apperrors.ErrTextureNotFound
} }
if texture.UploaderID != uploaderID { if texture.UploaderID != uploaderID {
return nil, ErrTextureNoPermission return nil, apperrors.ErrTextureNoPermission
} }
// 更新字段 // 更新字段
@@ -200,10 +206,10 @@ func (s *textureService) Delete(ctx context.Context, textureID, uploaderID int64
return err return err
} }
if texture == nil { if texture == nil {
return ErrTextureNotFound return apperrors.ErrTextureNotFound
} }
if texture.UploaderID != uploaderID { if texture.UploaderID != uploaderID {
return ErrTextureNoPermission return apperrors.ErrTextureNoPermission
} }
err = s.textureRepo.Delete(ctx, textureID) err = s.textureRepo.Delete(ctx, textureID)
@@ -225,33 +231,41 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i
return false, err return false, err
} }
if texture == nil { if texture == nil {
return false, ErrTextureNotFound return false, apperrors.ErrTextureNotFound
} }
isFavorited, err := s.textureRepo.IsFavorited(ctx, userID, textureID) // 在事务中执行"切换收藏 + 增减计数"两步,保证数据一致性
var favorited bool
err = database.TransactionWithTimeout(ctx, s.db, 5*time.Second, func(tx *gorm.DB) error {
isFav, err := s.textureRepo.IsFavorited(ctx, userID, textureID)
if err != nil {
return err
}
if isFav {
// 已收藏 -> 取消收藏
if err := s.textureRepo.RemoveFavorite(ctx, userID, textureID); err != nil {
return err
}
if err := s.textureRepo.DecrementFavoriteCount(ctx, textureID); err != nil {
return err
}
favorited = false
return nil
}
// 未收藏 -> 添加收藏
if err := s.textureRepo.AddFavorite(ctx, userID, textureID); err != nil {
return err
}
if err := s.textureRepo.IncrementFavoriteCount(ctx, textureID); err != nil {
return err
}
favorited = true
return nil
})
if err != nil { if err != nil {
return false, err return false, err
} }
return favorited, nil
if isFavorited {
// 已收藏 -> 取消收藏
if err := s.textureRepo.RemoveFavorite(ctx, userID, textureID); err != nil {
return false, err
}
if err := s.textureRepo.DecrementFavoriteCount(ctx, textureID); err != nil {
return false, err
}
return false, nil
}
// 未收藏 -> 添加收藏
if err := s.textureRepo.AddFavorite(ctx, userID, textureID); err != nil {
return false, err
}
if err := s.textureRepo.IncrementFavoriteCount(ctx, textureID); err != nil {
return false, err
}
return true, nil
} }
func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) { func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
@@ -277,7 +291,7 @@ func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, na
// 验证用户存在 // 验证用户存在
user, err := s.userRepo.FindByID(ctx, uploaderID) user, err := s.userRepo.FindByID(ctx, uploaderID)
if err != nil || user == nil { if err != nil || user == nil {
return nil, ErrUserNotFound return nil, apperrors.ErrUserNotFound
} }
// 验证文件大小和扩展名 // 验证文件大小和扩展名
@@ -330,10 +344,10 @@ func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, na
} }
// 生成对象名称(路径) // 生成对象名称(路径)
// 格式: hash/{hash[:2]}/{hash[2:4]}/{hash}.png // 格式: type/hash[:2]/hash
// 使用哈希值作为路径,避免重复存储相同文件 // 使用哈希值作为文件名,不带扩展名
textureTypeFolder := strings.ToLower(textureType) textureTypeFolder := strings.ToLower(textureType)
objectName := fmt.Sprintf("%s/%s/%s/%s/%s%s", textureTypeFolder, hash[:2], hash[2:4], hash, hash, ext) objectName := fmt.Sprintf("%s/%s", textureTypeFolder, hash)
// 上传文件 // 上传文件
reader := bytes.NewReader(fileData) reader := bytes.NewReader(fileData)
@@ -403,3 +417,28 @@ func parseTextureTypeInternal(textureType string) (model.TextureType, error) {
return "", errors.New("无效的材质类型") return "", errors.New("无效的材质类型")
} }
} }
// ==================== 管理员操作 ====================
// ListForAdmin 分页查询材质列表(管理员,含 Uploader 预加载)
func (s *textureService) ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) {
page, pageSize = NormalizePagination(page, pageSize)
return s.textureRepo.ListForAdmin(ctx, page, pageSize)
}
// AdminDelete 管理员删除材质(无权限校验)
func (s *textureService) AdminDelete(ctx context.Context, textureID int64) error {
texture, err := s.textureRepo.FindByIDForAdmin(ctx, textureID)
if err != nil {
return err
}
if texture == nil {
return apperrors.ErrTextureNotFound
}
return s.textureRepo.Delete(ctx, textureID)
}
// IncrementDownload 增加下载计数CustomSkinAPI 等场景使用)
func (s *textureService) IncrementDownload(ctx context.Context, textureID int64) error {
return s.textureRepo.IncrementDownloadCount(ctx, textureID)
}

View File

@@ -494,7 +494,7 @@ func TestTextureServiceImpl_Create(t *testing.T) {
_ = userRepo.Create(context.Background(), testUser) _ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
tests := []struct { tests := []struct {
name string name string
@@ -617,7 +617,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture) _ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
tests := []struct { tests := []struct {
name string name string
@@ -675,7 +675,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
} }
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background() ctx := context.Background()
@@ -714,7 +714,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
_ = textureRepo.Create(context.Background(), texture) _ = textureRepo.Create(context.Background(), texture)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background() ctx := context.Background()
@@ -764,7 +764,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
} }
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background() ctx := context.Background()
@@ -807,7 +807,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture) _ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background() ctx := context.Background()

View File

@@ -4,13 +4,12 @@ import (
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/internal/repository" "carrotskin/internal/repository"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"carrotskin/pkg/utils"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time" "time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -63,9 +62,13 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
} }
} }
// 生成ClientToken // 生成ClientToken使用32字符十六进制字符串
if clientToken == "" { if clientToken == "" {
clientToken = uuid.New().String() var err error
clientToken, err = utils.RandomHex(16) // 16字节 = 32字符十六进制
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成ClientToken失败: %w", err)
}
} }
// 获取或创建Client // 获取或创建Client
@@ -73,7 +76,10 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
existingClient, err := s.clientRepo.FindByClientToken(ctx, clientToken) existingClient, err := s.clientRepo.FindByClientToken(ctx, clientToken)
if err != nil { if err != nil {
// Client不存在创建新的 // Client不存在创建新的
clientUUID := uuid.New().String() clientUUID, err := utils.RandomHex(16) // 16字节 = 32字符十六进制
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成ClientUUID失败: %w", err)
}
client = &model.Client{ client = &model.Client{
UUID: clientUUID, UUID: clientUUID,
ClientToken: clientToken, ClientToken: clientToken,
@@ -166,13 +172,19 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
} }
if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil { if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil {
s.logger.Warn("存储Token到Redis失败", zap.Error(err)) // Redis 存储失败必须返回错误:否则 Validate 拿不到元数据Token 立即失效
// 不返回错误因为JWT本身已经生成成功 s.logger.Error("存储Token到Redis失败", zap.Error(err))
return nil, nil, "", "", fmt.Errorf("存储Token失败: %w", err)
} }
return selectedProfileID, availableProfiles, accessToken, clientToken, nil return selectedProfileID, availableProfiles, accessToken, clientToken, nil
} }
// CreateWithProfile 创建Token并绑定指定Profile使用JWT + Redis存储
func (s *tokenServiceRedis) CreateWithProfile(ctx context.Context, userID int64, profileUUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
return s.Create(ctx, userID, profileUUID, clientToken)
}
// Validate 验证Token使用JWT验证 + Redis存储验证 // Validate 验证Token使用JWT验证 + Redis存储验证
func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientToken string) bool { func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientToken string) bool {
// 设置超时上下文 // 设置超时上下文
@@ -461,7 +473,7 @@ func (s *tokenServiceRedis) validateProfileByUserID(ctx context.Context, userID
profile, err := s.profileRepo.FindByUUID(ctx, UUID) profile, err := s.profileRepo.FindByUUID(ctx, UUID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if repository.IsNotFound(err) {
return false, errors.New("配置文件不存在") return false, errors.New("配置文件不存在")
} }
return false, fmt.Errorf("验证配置文件失败: %w", err) return false, fmt.Errorf("验证配置文件失败: %w", err)

View File

@@ -26,6 +26,7 @@ import (
// userService UserService的实现 // userService UserService的实现
type userService struct { type userService struct {
cfg *config.Config
userRepo repository.UserRepository userRepo repository.UserRepository
jwtService *auth.JWTService jwtService *auth.JWTService
redis *redis.Client redis *redis.Client
@@ -38,6 +39,7 @@ type userService struct {
// NewUserService 创建UserService实例 // NewUserService 创建UserService实例
func NewUserService( func NewUserService(
cfg *config.Config,
userRepo repository.UserRepository, userRepo repository.UserRepository,
jwtService *auth.JWTService, jwtService *auth.JWTService,
redisClient *redis.Client, redisClient *redis.Client,
@@ -48,6 +50,7 @@ func NewUserService(
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀 // CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键 // 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
return &userService{ return &userService{
cfg: cfg,
userRepo: userRepo, userRepo: userRepo,
jwtService: jwtService, jwtService: jwtService,
redis: redisClient, redis: redisClient,
@@ -81,7 +84,7 @@ func (s *userService) Register(ctx context.Context, username, password, email, a
// 加密密码 // 加密密码
hashedPassword, err := auth.HashPassword(password) hashedPassword, err := auth.HashPassword(password)
if err != nil { if err != nil {
return nil, "", errors.New("密码加密失败") return nil, "", fmt.Errorf("密码加密失败: %w", err)
} }
// 确定头像URL // 确定头像URL
@@ -112,7 +115,7 @@ func (s *userService) Register(ctx context.Context, username, password, email, a
// 生成JWT Token // 生成JWT Token
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role) token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
if err != nil { if err != nil {
return nil, "", errors.New("生成Token失败") return nil, "", fmt.Errorf("生成Token失败: %w", err)
} }
return user, token, nil return user, token, nil
@@ -167,15 +170,20 @@ func (s *userService) Login(ctx context.Context, usernameOrEmail, password, ipAd
// 生成JWT Token // 生成JWT Token
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role) token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
if err != nil { if err != nil {
return nil, "", errors.New("生成Token失败") return nil, "", fmt.Errorf("生成Token失败: %w", err)
} }
// 更新最后登录时间 // 更新最后登录时间(非关键路径,错误降级为 Warn
now := time.Now() now := time.Now()
user.LastLoginAt = &now user.LastLoginAt = &now
_ = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{ if err := s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
"last_login_at": now, "last_login_at": now,
}) }); err != nil {
s.logger.Warn("更新最后登录时间失败",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
}
// 记录成功登录日志 // 记录成功登录日志
s.logSuccessLogin(ctx, user.ID, ipAddress, userAgent) s.logSuccessLogin(ctx, user.ID, ipAddress, userAgent)
@@ -239,7 +247,10 @@ func (s *userService) UpdateAvatar(ctx context.Context, userID int64, avatarURL
func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error { func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error {
user, err := s.userRepo.FindByID(ctx, userID) user, err := s.userRepo.FindByID(ctx, userID)
if err != nil || user == nil { if err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
if user == nil {
return errors.New("用户不存在") return errors.New("用户不存在")
} }
@@ -249,7 +260,7 @@ func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassw
hashedPassword, err := auth.HashPassword(newPassword) hashedPassword, err := auth.HashPassword(newPassword)
if err != nil { if err != nil {
return errors.New("密码加密失败") return fmt.Errorf("密码加密失败: %w", err)
} }
err = s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{ err = s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{
@@ -267,13 +278,16 @@ func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassw
func (s *userService) ResetPassword(ctx context.Context, email, newPassword string) error { func (s *userService) ResetPassword(ctx context.Context, email, newPassword string) error {
user, err := s.userRepo.FindByEmail(ctx, email) user, err := s.userRepo.FindByEmail(ctx, email)
if err != nil || user == nil { if err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
if user == nil {
return errors.New("用户不存在") return errors.New("用户不存在")
} }
hashedPassword, err := auth.HashPassword(newPassword) hashedPassword, err := auth.HashPassword(newPassword)
if err != nil { if err != nil {
return errors.New("密码加密失败") return fmt.Errorf("密码加密失败: %w", err)
} }
err = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{ err = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
@@ -350,14 +364,13 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e
return errors.New("URL缺少主机名") return errors.New("URL缺少主机名")
} }
// 从配置获取允许的域名列表 // 从注入的配置获取允许的域名列表
cfg, err := config.GetConfig() allowedDomains := s.cfg.Security.AllowedDomains
if err != nil { if len(allowedDomains) == 0 {
allowedDomains := []string{"localhost", "127.0.0.1"} allowedDomains = []string{"localhost", "127.0.0.1"}
return s.checkDomainAllowed(host, allowedDomains)
} }
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains) return s.checkDomainAllowed(host, allowedDomains)
} }
func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) { func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) {
@@ -422,29 +435,23 @@ func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData [
} }
func (s *userService) GetMaxProfilesPerUser() int { func (s *userService) GetMaxProfilesPerUser() int {
cfg, err := config.GetConfig() if s.cfg.Site.MaxProfilesPerUser <= 0 {
if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
return 5 return 5
} }
return cfg.Site.MaxProfilesPerUser return s.cfg.Site.MaxProfilesPerUser
} }
func (s *userService) GetMaxTexturesPerUser() int { func (s *userService) GetMaxTexturesPerUser() int {
cfg, err := config.GetConfig() if s.cfg.Site.MaxTexturesPerUser <= 0 {
if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
return 50 return 50
} }
return cfg.Site.MaxTexturesPerUser return s.cfg.Site.MaxTexturesPerUser
} }
// 私有辅助方法 // 私有辅助方法
func (s *userService) getDefaultAvatar() string { func (s *userService) getDefaultAvatar() string {
cfg, err := config.GetConfig() return s.cfg.Site.DefaultAvatar
if err != nil {
return ""
}
return cfg.Site.DefaultAvatar
} }
func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error { func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error {
@@ -505,3 +512,21 @@ func (s *userService) logFailedLogin(ctx context.Context, userID int64, ipAddres
} }
_ = s.userRepo.CreateLoginLog(ctx, log) _ = s.userRepo.CreateLoginLog(ctx, log)
} }
// ==================== 管理员操作 ====================
// ListUsers 分页查询用户列表(管理员)
func (s *userService) ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) {
page, pageSize = NormalizePagination(page, pageSize)
return s.userRepo.List(ctx, page, pageSize)
}
// SetRole 设置用户角色(管理员)
func (s *userService) SetRole(ctx context.Context, userID int64, role string) error {
return s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{"role": role})
}
// SetStatus 设置用户状态(管理员)
func (s *userService) SetStatus(ctx context.Context, userID int64, status int16) error {
return s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{"status": status})
}

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"carrotskin/pkg/config"
"context" "context"
"testing" "testing"
@@ -14,11 +15,12 @@ func TestUserServiceImpl_Register(t *testing.T) {
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
cfg := &config.Config{}
// 初始化Service // 初始化Service
// 注意redisClient 和 storageClient 传入 nil因为 Register 方法中没有使用它们 // 注意redisClient 和 storageClient 传入 nil因为 Register 方法中没有使用它们
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(cfg, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -128,7 +130,8 @@ func TestUserServiceImpl_Login(t *testing.T) {
_ = userRepo.Create(context.Background(), testUser) _ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) cfg := &config.Config{}
userService := NewUserService(cfg, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -208,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, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -255,7 +258,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -289,7 +292,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -316,7 +319,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
_ = userRepo.Create(context.Background(), user2) _ = userRepo.Create(context.Background(), user2)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -338,7 +341,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
logger := zap.NewNop() logger := zap.NewNop()
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -374,7 +377,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
// 未配置时走默认值 // 未配置时走默认值
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
if got := userService.GetMaxProfilesPerUser(); got != 5 { if got := userService.GetMaxProfilesPerUser(); got != 5 {
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got) t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
} }

View File

@@ -26,16 +26,19 @@ const (
// verificationService VerificationService的实现 // verificationService VerificationService的实现
type verificationService struct { type verificationService struct {
cfg *config.Config
redis *redis.Client redis *redis.Client
emailService *email.Service emailService *email.Service
} }
// NewVerificationService 创建VerificationService实例 // NewVerificationService 创建VerificationService实例
func NewVerificationService( func NewVerificationService(
cfg *config.Config,
redisClient *redis.Client, redisClient *redis.Client,
emailService *email.Service, emailService *email.Service,
) VerificationService { ) VerificationService {
return &verificationService{ return &verificationService{
cfg: cfg,
redis: redisClient, redis: redisClient,
emailService: emailService, emailService: emailService,
} }
@@ -44,8 +47,7 @@ func NewVerificationService(
// SendCode 发送验证码 // SendCode 发送验证码
func (s *verificationService) SendCode(ctx context.Context, email, codeType string) error { func (s *verificationService) SendCode(ctx context.Context, email, codeType string) error {
// 测试环境下直接跳过,不存储也不发送 // 测试环境下直接跳过,不存储也不发送
cfg, err := config.GetConfig() if s.cfg.IsTestEnvironment() {
if err == nil && cfg.IsTestEnvironment() {
return nil return nil
} }
@@ -89,8 +91,7 @@ func (s *verificationService) SendCode(ctx context.Context, email, codeType stri
// VerifyCode 验证验证码 // VerifyCode 验证验证码
func (s *verificationService) VerifyCode(ctx context.Context, email, code, codeType string) error { func (s *verificationService) VerifyCode(ctx context.Context, email, code, codeType string) error {
// 测试环境下直接通过验证 // 测试环境下直接通过验证
cfg, err := config.GetConfig() if s.cfg.IsTestEnvironment() {
if err == nil && cfg.IsTestEnvironment() {
return nil return nil
} }
@@ -166,8 +167,5 @@ func (s *verificationService) sendEmail(to, code, codeType string) error {
} }
} }
// DeleteVerificationCode 删除验证码(工具函数,保持向后兼容) // DeleteVerificationCode 已移除:重构后无调用方。如需删除验证码,请使用
func DeleteVerificationCode(ctx context.Context, redisClient *redis.Client, email, codeType string) error { // VerificationService 实现内的 redis.Del 路径。
codeKey := fmt.Sprintf("verification:code:%s:%s", codeType, email)
return redisClient.Del(ctx, codeKey)
}

View File

@@ -9,14 +9,11 @@ import (
"fmt" "fmt"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
) )
// yggdrasilAuthService Yggdrasil认证服务实现 // yggdrasilAuthService Yggdrasil认证服务实现
// 负责认证和密码管理 // 负责认证和密码管理
type yggdrasilAuthService struct { type yggdrasilAuthService struct {
db *gorm.DB
userRepo repository.UserRepository userRepo repository.UserRepository
yggdrasilRepo repository.YggdrasilRepository yggdrasilRepo repository.YggdrasilRepository
logger *zap.Logger logger *zap.Logger
@@ -24,13 +21,11 @@ type yggdrasilAuthService struct {
// NewYggdrasilAuthService 创建Yggdrasil认证服务实例内部使用 // NewYggdrasilAuthService 创建Yggdrasil认证服务实例内部使用
func NewYggdrasilAuthService( func NewYggdrasilAuthService(
db *gorm.DB,
userRepo repository.UserRepository, userRepo repository.UserRepository,
yggdrasilRepo repository.YggdrasilRepository, yggdrasilRepo repository.YggdrasilRepository,
logger *zap.Logger, logger *zap.Logger,
) *yggdrasilAuthService { ) *yggdrasilAuthService {
return &yggdrasilAuthService{ return &yggdrasilAuthService{
db: db,
userRepo: userRepo, userRepo: userRepo,
yggdrasilRepo: yggdrasilRepo, yggdrasilRepo: yggdrasilRepo,
logger: logger, logger: logger,
@@ -78,7 +73,7 @@ func (s *yggdrasilAuthService) ResetYggdrasilPassword(ctx context.Context, userI
ID: userID, ID: userID,
Password: hashedPassword, Password: hashedPassword,
} }
if err := s.db.Create(&yggdrasil).Error; err != nil { if err := s.yggdrasilRepo.Create(ctx, &yggdrasil); err != nil {
return "", fmt.Errorf("创建Yggdrasil密码失败: %w", err) return "", fmt.Errorf("创建Yggdrasil密码失败: %w", err)
} }
return plainPassword, nil return plainPassword, nil

View File

@@ -64,7 +64,7 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
s.logger.Info("为用户创建新的密钥对", s.logger.Info("为用户创建新的密钥对",
zap.String("uuid", uuid), zap.String("uuid", uuid),
) )
keyPair, err = s.signatureService.NewKeyPair() keyPair, err = s.signatureService.NewKeyPair(ctx)
if err != nil { if err != nil {
s.logger.Error("生成玩家证书密钥对失败", s.logger.Error("生成玩家证书密钥对失败",
zap.Error(err), zap.Error(err),
@@ -107,6 +107,6 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
// GetPublicKey 获取公钥 // GetPublicKey 获取公钥
func (s *yggdrasilCertificateService) GetPublicKey(ctx context.Context) (string, error) { func (s *yggdrasilCertificateService) GetPublicKey(ctx context.Context) (string, error) {
return s.signatureService.GetPublicKeyFromRedis() return s.signatureService.GetPublicKeyFromRedis(ctx)
} }

View File

@@ -14,6 +14,8 @@ import (
type SerializationService interface { type SerializationService interface {
// SerializeProfile 序列化档案为Yggdrasil格式 // SerializeProfile 序列化档案为Yggdrasil格式
SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{}
// SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式支持unsigned参数
SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{}
// SerializeUser 序列化用户为Yggdrasil格式 // SerializeUser 序列化用户为Yggdrasil格式
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
} }
@@ -45,8 +47,13 @@ func NewSerializationService(
} }
} }
// SerializeProfile 序列化档案为Yggdrasil格式 // SerializeProfile 序列化档案为Yggdrasil格式(默认返回签名)
func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} { func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} {
return s.SerializeProfileWithUnsigned(ctx, profile, false)
}
// SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式支持unsigned参数
func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} {
// 创建基本材质数据 // 创建基本材质数据
texturesMap := make(map[string]interface{}) texturesMap := make(map[string]interface{})
textures := map[string]interface{}{ textures := map[string]interface{}{
@@ -99,26 +106,36 @@ func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, pr
} }
textureData := base64.StdEncoding.EncodeToString(bytes) textureData := base64.StdEncoding.EncodeToString(bytes)
signature, err := s.signatureService.SignStringWithSHA1withRSA(textureData)
if err != nil { // 只有在 unsigned=false 时才签名
s.logger.Error("签名textures失败", var signature string
zap.Error(err), if !unsigned {
zap.String("profileUUID", profile.UUID), signature, err = s.signatureService.SignStringWithSHA1withRSA(ctx, textureData)
) if err != nil {
return nil s.logger.Error("签名textures失败",
zap.Error(err),
zap.String("profileUUID", profile.UUID),
)
return nil
}
}
// 构建属性
property := Property{
Name: "textures",
Value: textureData,
}
// 只有在 unsigned=false 时才添加签名
if !unsigned {
property.Signature = signature
} }
// 构建结果 // 构建结果
data := map[string]interface{}{ data := map[string]interface{}{
"id": profile.UUID, "id": profile.UUID,
"name": profile.Name, "name": profile.Name,
"properties": []Property{ "properties": []Property{property},
{
Name: "textures",
Value: textureData,
Signature: signature,
},
},
} }
return data return data
} }

View File

@@ -10,8 +10,6 @@ import (
"fmt" "fmt"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
) )
// yggdrasilServiceComposite 组合服务,保持接口兼容性 // yggdrasilServiceComposite 组合服务,保持接口兼容性
@@ -28,20 +26,20 @@ type yggdrasilServiceComposite struct {
// NewYggdrasilServiceComposite 创建组合服务实例 // NewYggdrasilServiceComposite 创建组合服务实例
func NewYggdrasilServiceComposite( func NewYggdrasilServiceComposite(
db *gorm.DB,
userRepo repository.UserRepository, userRepo repository.UserRepository,
profileRepo repository.ProfileRepository, profileRepo repository.ProfileRepository,
yggdrasilRepo repository.YggdrasilRepository, yggdrasilRepo repository.YggdrasilRepository,
textureRepo repository.TextureRepository,
signatureService *SignatureService, signatureService *SignatureService,
redisClient *redis.Client, redisClient *redis.Client,
logger *zap.Logger, logger *zap.Logger,
tokenService TokenService, // 新增TokenService接口 tokenService TokenService, // 新增TokenService接口
) YggdrasilService { ) YggdrasilService {
// 创建各个专门的服务 // 创建各个专门的服务
authService := NewYggdrasilAuthService(db, userRepo, yggdrasilRepo, logger) authService := NewYggdrasilAuthService(userRepo, yggdrasilRepo, logger)
sessionService := NewSessionService(redisClient, logger) sessionService := NewSessionService(redisClient, logger)
serializationService := NewSerializationService( serializationService := NewSerializationService(
repository.NewTextureRepository(db), textureRepo,
signatureService, signatureService,
logger, logger,
) )
@@ -85,8 +83,8 @@ func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, ac
return fmt.Errorf("验证Token失败: %w", err) return fmt.Errorf("验证Token失败: %w", err)
} }
// 格式化UUID并验证与Token关联的配置文件 // 确保UUID是32位无符号格式用于向后兼容
formattedProfile := utils.FormatUUID(selectedProfile) formattedProfile := utils.FormatUUIDToNoDash(selectedProfile)
if uuid != formattedProfile { if uuid != formattedProfile {
return errors.New("selectedProfile与Token不匹配") return errors.New("selectedProfile与Token不匹配")
} }
@@ -115,6 +113,11 @@ func (s *yggdrasilServiceComposite) SerializeProfile(ctx context.Context, profil
return s.serializationService.SerializeProfile(ctx, profile) return s.serializationService.SerializeProfile(ctx, profile)
} }
// SerializeProfileWithUnsigned 序列化档案支持unsigned参数
func (s *yggdrasilServiceComposite) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} {
return s.serializationService.SerializeProfileWithUnsigned(ctx, profile, unsigned)
}
// SerializeUser 序列化用户 // SerializeUser 序列化用户
func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} { func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} {
return s.serializationService.SerializeUser(ctx, user, uuid) return s.serializationService.SerializeUser(ctx, user, uuid)

View File

@@ -57,16 +57,38 @@ func (v *Validator) ValidateEmail(email string) error {
return nil return nil
} }
// ValidateUUID 验证UUID格式简单验证 // ValidateUUID 验证UUID格式支持32位无符号和36位带连字符格式
func (v *Validator) ValidateUUID(uuid string) error { func (v *Validator) ValidateUUID(uuid string) error {
if uuid == "" { if uuid == "" {
return errors.New("UUID不能为空") return errors.New("UUID不能为空")
} }
// UUID格式xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (32个十六进制字符 + 4个连字符)
if len(uuid) < 32 || len(uuid) > 36 { // 验证32位无符号UUID格式纯十六进制字符串
return errors.New("UUID格式无效") if len(uuid) == 32 {
// 检查是否为有效的十六进制字符串
for _, c := range uuid {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return errors.New("UUID格式无效包含非十六进制字符")
}
}
return nil
} }
return nil
// 验证36位标准UUID格式带连字符
if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' {
// 检查除连字符外的字符是否为有效的十六进制
for i, c := range uuid {
if i == 8 || i == 13 || i == 18 || i == 23 {
continue // 跳过连字符位置
}
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return errors.New("UUID格式无效包含非十六进制字符")
}
}
return nil
}
return errors.New("UUID格式无效长度应为32位或36位")
} }
// ValidateAccessToken 验证访问令牌 // ValidateAccessToken 验证访问令牌

View File

@@ -2,13 +2,8 @@ package types
import "time" import "time"
// BaseResponse 基础响应结构 // 注意:BaseResponse 与 PaginationResponse 已删除(与 model.Response /
// @Description 通用API响应结构 // model.PaginationResponse 重复)。统一使用 model 包的响应结构
type BaseResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// PaginationRequest 分页请求 // PaginationRequest 分页请求
// @Description 分页查询参数 // @Description 分页查询参数
@@ -17,16 +12,6 @@ type PaginationRequest struct {
PageSize int `json:"page_size" form:"page_size" binding:"omitempty,min=1,max=100"` PageSize int `json:"page_size" form:"page_size" binding:"omitempty,min=1,max=100"`
} }
// PaginationResponse 分页响应
// @Description 分页查询结果
type PaginationResponse struct {
List interface{} `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// LoginRequest 登录请求 // LoginRequest 登录请求
// @Description 用户登录请求参数 // @Description 用户登录请求参数
type LoginRequest struct { type LoginRequest struct {
@@ -41,6 +26,7 @@ type RegisterRequest struct {
Email string `json:"email" binding:"required,email" example:"user@example.com"` Email string `json:"email" binding:"required,email" example:"user@example.com"`
Password string `json:"password" binding:"required,min=6,max=128" example:"password123"` Password string `json:"password" binding:"required,min=6,max=128" example:"password123"`
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` // 邮箱验证码 VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` // 邮箱验证码
CaptchaID string `json:"captcha_id" binding:"required" example:"uuid-xxxx-xxxx"` // 滑动验证码ID
Avatar string `json:"avatar" binding:"omitempty,url" example:"https://rustfs.example.com/avatars/user_1/avatar.png"` // 可选,用户自定义头像 Avatar string `json:"avatar" binding:"omitempty,url" example:"https://rustfs.example.com/avatars/user_1/avatar.png"` // 可选,用户自定义头像
} }
@@ -154,7 +140,7 @@ type TextureInfo struct {
// ProfileInfo 角色信息 // ProfileInfo 角色信息
// @Description Minecraft档案信息 // @Description Minecraft档案信息
type ProfileInfo struct { type ProfileInfo struct {
UUID string `json:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` UUID string `json:"uuid" example:"550e8400e29b41d4a716446655440000"`
UserID int64 `json:"user_id" example:"1"` UserID int64 `json:"user_id" example:"1"`
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"`

View File

@@ -7,9 +7,9 @@ import (
// TestPaginationRequest_Validation 测试分页请求验证逻辑 // TestPaginationRequest_Validation 测试分页请求验证逻辑
func TestPaginationRequest_Validation(t *testing.T) { func TestPaginationRequest_Validation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
page int page int
pageSize int pageSize int
wantValid bool wantValid bool
}{ }{
{ {
@@ -63,102 +63,15 @@ func TestTextureType_Constants(t *testing.T) {
} }
} }
// TestPaginationResponse_Structure 测试分页响应结构 // TestPaginationRequest_Defaults 测试分页请求结构
func TestPaginationResponse_Structure(t *testing.T) { // 注意:PaginationResponse 已删除(与 model.PaginationResponse 重复)
resp := PaginationResponse{ func TestPaginationRequest_Defaults(t *testing.T) {
List: []string{"a", "b", "c"}, req := PaginationRequest{Page: 1, PageSize: 20}
Total: 100, if req.Page != 1 {
Page: 1, t.Errorf("Page = %d, want 1", req.Page)
PageSize: 20,
TotalPages: 5,
} }
if req.PageSize != 20 {
if resp.Total != 100 { t.Errorf("PageSize = %d, want 20", req.PageSize)
t.Errorf("Total = %d, want 100", resp.Total)
}
if resp.Page != 1 {
t.Errorf("Page = %d, want 1", resp.Page)
}
if resp.PageSize != 20 {
t.Errorf("PageSize = %d, want 20", resp.PageSize)
}
if resp.TotalPages != 5 {
t.Errorf("TotalPages = %d, want 5", resp.TotalPages)
}
}
// TestPaginationResponse_TotalPagesCalculation 测试总页数计算逻辑
func TestPaginationResponse_TotalPagesCalculation(t *testing.T) {
tests := []struct {
name string
total int64
pageSize int
wantPages int
}{
{
name: "正好整除",
total: 100,
pageSize: 20,
wantPages: 5,
},
{
name: "有余数",
total: 101,
pageSize: 20,
wantPages: 6, // 向上取整
},
{
name: "总数小于每页数量",
total: 10,
pageSize: 20,
wantPages: 1,
},
{
name: "总数为0",
total: 0,
pageSize: 20,
wantPages: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 计算总页数:向上取整
var totalPages int
if tt.total == 0 {
totalPages = 0
} else {
totalPages = int((tt.total + int64(tt.pageSize) - 1) / int64(tt.pageSize))
}
if totalPages != tt.wantPages {
t.Errorf("TotalPages = %d, want %d", totalPages, tt.wantPages)
}
})
}
}
// TestBaseResponse_Structure 测试基础响应结构
func TestBaseResponse_Structure(t *testing.T) {
resp := BaseResponse{
Code: 200,
Message: "success",
Data: "test data",
}
if resp.Code != 200 {
t.Errorf("Code = %d, want 200", resp.Code)
}
if resp.Message != "success" {
t.Errorf("Message = %q, want 'success'", resp.Message)
}
if resp.Data != "test data" {
t.Errorf("Data = %v, want 'test data'", resp.Data)
} }
} }
@@ -170,36 +83,10 @@ func TestLoginRequest_Validation(t *testing.T) {
password string password string
wantValid bool wantValid bool
}{ }{
{ {name: "有效的登录请求", username: "testuser", password: "password123", wantValid: true},
name: "有效的登录请求", {name: "用户名为空", username: "", password: "password123", wantValid: false},
username: "testuser", {name: "密码为空", username: "testuser", password: "", wantValid: false},
password: "password123", {name: "密码长度小于6", username: "testuser", password: "12345", wantValid: false},
wantValid: true,
},
{
name: "用户名为空",
username: "",
password: "password123",
wantValid: false,
},
{
name: "密码为空",
username: "testuser",
password: "",
wantValid: false,
},
{
name: "密码长度小于6",
username: "testuser",
password: "12345",
wantValid: false,
},
{
name: "密码长度超过128",
username: "testuser",
password: string(make([]byte, 129)),
wantValid: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -211,174 +98,3 @@ func TestLoginRequest_Validation(t *testing.T) {
}) })
} }
} }
// TestRegisterRequest_Validation 测试注册请求验证逻辑
func TestRegisterRequest_Validation(t *testing.T) {
tests := []struct {
name string
username string
email string
password string
verificationCode string
wantValid bool
}{
{
name: "有效的注册请求",
username: "newuser",
email: "user@example.com",
password: "password123",
verificationCode: "123456",
wantValid: true,
},
{
name: "用户名为空",
username: "",
email: "user@example.com",
password: "password123",
verificationCode: "123456",
wantValid: false,
},
{
name: "用户名长度小于3",
username: "ab",
email: "user@example.com",
password: "password123",
verificationCode: "123456",
wantValid: false,
},
{
name: "用户名长度超过50",
username: string(make([]byte, 51)),
email: "user@example.com",
password: "password123",
verificationCode: "123456",
wantValid: false,
},
{
name: "邮箱格式无效",
username: "newuser",
email: "invalid-email",
password: "password123",
verificationCode: "123456",
wantValid: false,
},
{
name: "验证码长度不是6",
username: "newuser",
email: "user@example.com",
password: "password123",
verificationCode: "12345",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.username != "" &&
len(tt.username) >= 3 && len(tt.username) <= 50 &&
tt.email != "" && contains(tt.email, "@") &&
len(tt.password) >= 6 && len(tt.password) <= 128 &&
len(tt.verificationCode) == 6
if isValid != tt.wantValid {
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// 辅助函数
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// TestResetPasswordRequest_Validation 测试重置密码请求验证
func TestResetPasswordRequest_Validation(t *testing.T) {
tests := []struct {
name string
email string
verificationCode string
newPassword string
wantValid bool
}{
{
name: "有效的重置密码请求",
email: "user@example.com",
verificationCode: "123456",
newPassword: "newpassword123",
wantValid: true,
},
{
name: "邮箱为空",
email: "",
verificationCode: "123456",
newPassword: "newpassword123",
wantValid: false,
},
{
name: "验证码长度不是6",
email: "user@example.com",
verificationCode: "12345",
newPassword: "newpassword123",
wantValid: false,
},
{
name: "新密码长度小于6",
email: "user@example.com",
verificationCode: "123456",
newPassword: "12345",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.email != "" &&
len(tt.verificationCode) == 6 &&
len(tt.newPassword) >= 6 && len(tt.newPassword) <= 128
if isValid != tt.wantValid {
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestCreateProfileRequest_Validation 测试创建档案请求验证
func TestCreateProfileRequest_Validation(t *testing.T) {
tests := []struct {
name string
profileName string
wantValid bool
}{
{
name: "有效的档案名",
profileName: "PlayerName",
wantValid: true,
},
{
name: "档案名为空",
profileName: "",
wantValid: false,
},
{
name: "档案名长度超过16",
profileName: string(make([]byte, 17)),
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.profileName != "" &&
len(tt.profileName) >= 1 && len(tt.profileName) <= 16
if isValid != tt.wantValid {
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}

View File

@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"sync" "sync"
"github.com/casbin/casbin/v2" "github.com/casbin/casbin/v3"
gormadapter "github.com/casbin/gorm-adapter/v3" gormadapter "github.com/casbin/gorm-adapter/v3"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"

View File

@@ -1,46 +1,8 @@
package auth package auth
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetJWTService/MustGetJWTService
"carrotskin/pkg/config" //
"fmt" // 在 DI 迁移阶段4全局单例已被移除。JWT 服务由 fx.Provide 构造
"sync" // (见 internal/app/infra_module.go通过构造函数参数注入到各消费者。
) //
// JWT 服务构造逻辑见 jwt.go 的 NewJWTService()。
var (
// jwtServiceInstance 全局JWT服务实例
jwtServiceInstance *JWTService
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
)
// Init 初始化JWT服务线程安全只会执行一次
func Init(cfg config.JWTConfig) error {
once.Do(func() {
jwtServiceInstance = NewJWTService(cfg.Secret, cfg.ExpireHours)
})
return nil
}
// GetJWTService 获取JWT服务实例线程安全
func GetJWTService() (*JWTService, error) {
if jwtServiceInstance == nil {
return nil, fmt.Errorf("JWT服务未初始化请先调用 auth.Init()")
}
return jwtServiceInstance, nil
}
// MustGetJWTService 获取JWT服务实例如果未初始化则panic
func MustGetJWTService() *JWTService {
service, err := GetJWTService()
if err != nil {
panic(err)
}
return service
}

View File

@@ -1,86 +0,0 @@
package auth
import (
"carrotskin/pkg/config"
"testing"
)
// TestGetJWTService_NotInitialized 测试未初始化时获取JWT服务
func TestGetJWTService_NotInitialized(t *testing.T) {
_, err := GetJWTService()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "JWT服务未初始化请先调用 auth.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetJWTService_Panic 测试MustGetJWTService在未初始化时panic
func TestMustGetJWTService_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetJWTService 应该在未初始化时panic")
}
}()
_ = MustGetJWTService()
}
// TestInit_JWTService 测试JWT服务初始化
func TestInit_JWTService(t *testing.T) {
cfg := config.JWTConfig{
Secret: "test-secret-key",
ExpireHours: 24,
}
err := Init(cfg)
if err != nil {
t.Errorf("Init() 错误 = %v, want nil", err)
}
// 验证可以获取服务
service, err := GetJWTService()
if err != nil {
t.Errorf("GetJWTService() 错误 = %v, want nil", err)
}
if service == nil {
t.Error("GetJWTService() 返回的服务不应为nil")
}
}
// TestInit_JWTService_Once 测试Init只执行一次
func TestInit_JWTService_Once(t *testing.T) {
cfg := config.JWTConfig{
Secret: "test-secret-key-1",
ExpireHours: 24,
}
// 第一次初始化
err1 := Init(cfg)
if err1 != nil {
t.Fatalf("第一次Init() 错误 = %v", err1)
}
service1, _ := GetJWTService()
// 第二次初始化(应该不会改变服务)
cfg2 := config.JWTConfig{
Secret: "test-secret-key-2",
ExpireHours: 48,
}
err2 := Init(cfg2)
if err2 != nil {
t.Fatalf("第二次Init() 错误 = %v", err2)
}
service2, _ := GetJWTService()
// 验证是同一个实例sync.Once保证
if service1 != service2 {
t.Error("Init应该只执行一次返回同一个实例")
}
}

View File

@@ -65,18 +65,21 @@ type DatabaseConfig struct {
// RedisConfig Redis配置 // RedisConfig Redis配置
type RedisConfig struct { type RedisConfig struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
Password string `mapstructure:"password"` Password string `mapstructure:"password"`
Database int `mapstructure:"database"` Database int `mapstructure:"database"`
PoolSize int `mapstructure:"pool_size"` // 连接池大小 PoolSize int `mapstructure:"pool_size"` // 连接池大小
MinIdleConns int `mapstructure:"min_idle_conns"` // 最小空闲连接数 MinIdleConns int `mapstructure:"min_idle_conns"` // 最小空闲连接数
MaxRetries int `mapstructure:"max_retries"` // 最大重试次数 MaxRetries int `mapstructure:"max_retries"` // 最大重试次数
DialTimeout time.Duration `mapstructure:"dial_timeout"` // 连接超时 DialTimeout time.Duration `mapstructure:"dial_timeout"` // 连接超时
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 读取超时 ReadTimeout time.Duration `mapstructure:"read_timeout"` // 读取超时
WriteTimeout time.Duration `mapstructure:"write_timeout"` // 写入超时 WriteTimeout time.Duration `mapstructure:"write_timeout"` // 写入超时
PoolTimeout time.Duration `mapstructure:"pool_timeout"` // 连接池超时 PoolTimeout time.Duration `mapstructure:"pool_timeout"` // 连接池超时
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"` // 连接最大空闲时间 ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"` // 连接最大空闲时间
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` // 连接最大生命周期(新增)
HealthCheckInterval time.Duration `mapstructure:"health_check_interval"` // 健康检查间隔(新增)
EnableRetryOnError bool `mapstructure:"enable_retry_on_error"` // 错误时启用重试(新增)
} }
// RustFSConfig RustFS对象存储配置 (S3兼容) // RustFSConfig RustFS对象存储配置 (S3兼容)
@@ -192,18 +195,21 @@ func setDefaults() {
viper.SetDefault("database.conn_max_lifetime", "1h") viper.SetDefault("database.conn_max_lifetime", "1h")
viper.SetDefault("database.conn_max_idle_time", "10m") viper.SetDefault("database.conn_max_idle_time", "10m")
// Redis默认配置 // Redis默认配置(优化后的默认值)
viper.SetDefault("redis.host", "localhost") viper.SetDefault("redis.host", "localhost")
viper.SetDefault("redis.port", 6379) viper.SetDefault("redis.port", 6379)
viper.SetDefault("redis.database", 0) viper.SetDefault("redis.database", 0)
viper.SetDefault("redis.pool_size", 10) viper.SetDefault("redis.pool_size", 16) // 优化:提高默认连接池大小
viper.SetDefault("redis.min_idle_conns", 5) viper.SetDefault("redis.min_idle_conns", 8) // 优化:提高最小空闲连接数
viper.SetDefault("redis.max_retries", 3) viper.SetDefault("redis.max_retries", 3)
viper.SetDefault("redis.dial_timeout", "5s") viper.SetDefault("redis.dial_timeout", "5s")
viper.SetDefault("redis.read_timeout", "3s") viper.SetDefault("redis.read_timeout", "3s")
viper.SetDefault("redis.write_timeout", "3s") viper.SetDefault("redis.write_timeout", "3s")
viper.SetDefault("redis.pool_timeout", "4s") viper.SetDefault("redis.pool_timeout", "4s")
viper.SetDefault("redis.conn_max_idle_time", "30m") viper.SetDefault("redis.conn_max_idle_time", "10m") // 优化:减少空闲连接超时时间
viper.SetDefault("redis.conn_max_lifetime", "30m") // 新增:连接最大生命周期
viper.SetDefault("redis.health_check_interval", "30s") // 新增:健康检查间隔
viper.SetDefault("redis.enable_retry_on_error", true) // 新增:错误时启用重试
// RustFS默认配置 // RustFS默认配置
viper.SetDefault("rustfs.endpoint", "127.0.0.1:9000") viper.SetDefault("rustfs.endpoint", "127.0.0.1:9000")
@@ -281,6 +287,9 @@ func setupEnvMappings() {
viper.BindEnv("redis.write_timeout", "REDIS_WRITE_TIMEOUT") viper.BindEnv("redis.write_timeout", "REDIS_WRITE_TIMEOUT")
viper.BindEnv("redis.pool_timeout", "REDIS_POOL_TIMEOUT") viper.BindEnv("redis.pool_timeout", "REDIS_POOL_TIMEOUT")
viper.BindEnv("redis.conn_max_idle_time", "REDIS_CONN_MAX_IDLE_TIME") viper.BindEnv("redis.conn_max_idle_time", "REDIS_CONN_MAX_IDLE_TIME")
viper.BindEnv("redis.conn_max_lifetime", "REDIS_CONN_MAX_LIFETIME")
viper.BindEnv("redis.health_check_interval", "REDIS_HEALTH_CHECK_INTERVAL")
viper.BindEnv("redis.enable_retry_on_error", "REDIS_ENABLE_RETRY_ON_ERROR")
// RustFS配置 // RustFS配置
viper.BindEnv("rustfs.endpoint", "RUSTFS_ENDPOINT") viper.BindEnv("rustfs.endpoint", "RUSTFS_ENDPOINT")
@@ -427,6 +436,22 @@ func overrideFromEnv(config *Config) {
} }
} }
if connMaxLifetime := os.Getenv("REDIS_CONN_MAX_LIFETIME"); connMaxLifetime != "" {
if val, err := time.ParseDuration(connMaxLifetime); err == nil {
config.Redis.ConnMaxLifetime = val
}
}
if healthCheckInterval := os.Getenv("REDIS_HEALTH_CHECK_INTERVAL"); healthCheckInterval != "" {
if val, err := time.ParseDuration(healthCheckInterval); err == nil {
config.Redis.HealthCheckInterval = val
}
}
if enableRetryOnError := os.Getenv("REDIS_ENABLE_RETRY_ON_ERROR"); enableRetryOnError != "" {
config.Redis.EnableRetryOnError = enableRetryOnError == "true" || enableRetryOnError == "1"
}
// 处理邮件配置 // 处理邮件配置
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" { if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1" config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"

View File

@@ -1,71 +1,8 @@
package config package config
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetConfig/MustGetConfig/GetRustFSConfig
"fmt" //
"sync" // 在 DI 迁移阶段4全局单例已被移除。配置通过 config.Load() 加载,
) // 由 fx.Supply 注入到依赖图中。各消费者通过构造函数参数接收 *Config 或子配置。
//
var ( // 配置加载逻辑见 config.go 的 Load()。
// configInstance 全局配置实例
configInstance *Config
// rustFSConfigInstance 全局RustFS配置实例
rustFSConfigInstance *RustFSConfig
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
initError error
)
// Init 初始化配置(线程安全,只会执行一次)
func Init() error {
once.Do(func() {
configInstance, initError = Load()
if initError != nil {
return
}
rustFSConfigInstance = &configInstance.RustFS
})
return initError
}
// GetConfig 获取配置实例(线程安全)
func GetConfig() (*Config, error) {
if configInstance == nil {
return nil, fmt.Errorf("配置未初始化,请先调用 config.Init()")
}
return configInstance, nil
}
// MustGetConfig 获取配置实例如果未初始化则panic
func MustGetConfig() *Config {
cfg, err := GetConfig()
if err != nil {
panic(err)
}
return cfg
}
// GetRustFSConfig 获取RustFS配置实例线程安全
func GetRustFSConfig() (*RustFSConfig, error) {
if rustFSConfigInstance == nil {
return nil, fmt.Errorf("配置未初始化,请先调用 config.Init()")
}
return rustFSConfigInstance, nil
}
// MustGetRustFSConfig 获取RustFS配置实例如果未初始化则panic
func MustGetRustFSConfig() *RustFSConfig {
cfg, err := GetRustFSConfig()
if err != nil {
panic(err)
}
return cfg
}

View File

@@ -4,67 +4,19 @@ import (
"testing" "testing"
) )
// TestGetConfig_NotInitialized 测试未初始化时获取配置 // TestLoad_Config 验证 Load() 能成功加载配置(从环境变量/默认值)
func TestGetConfig_NotInitialized(t *testing.T) { // 全局单例访问器GetConfig/MustGetConfig已在 DI 迁移中移除,
// 重置全局变量(在实际测试中可能需要更复杂的重置逻辑) // 配置通过 config.Load() 加载并由 fx.Supply 注入。
// 注意:由于使用了 sync.Once这个测试主要验证错误处理逻辑 func TestLoad_Config(t *testing.T) {
cfg, err := Load()
// 测试未初始化时的错误消息 if err != nil {
_, err := GetConfig() t.Fatalf("Load() 返回错误: %v", err)
if err == nil {
t.Error("未初始化时应该返回错误")
} }
if cfg == nil {
expectedError := "配置未初始化,请先调用 config.Init()" t.Fatal("Load() 返回 nil 配置")
if err.Error() != expectedError { }
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError) // 验证默认值生效
if cfg.Server.Port == "" {
t.Error("Server.Port 不应为空")
} }
} }
// TestMustGetConfig_Panic 测试MustGetConfig在未初始化时panic
func TestMustGetConfig_Panic(t *testing.T) {
// 注意这个测试会触发panic需要recover
defer func() {
if r := recover(); r == nil {
t.Error("MustGetConfig 应该在未初始化时panic")
}
}()
// 尝试获取未初始化的配置
_ = MustGetConfig()
}
// TestGetRustFSConfig_NotInitialized 测试未初始化时获取RustFS配置
func TestGetRustFSConfig_NotInitialized(t *testing.T) {
_, err := GetRustFSConfig()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "配置未初始化,请先调用 config.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetRustFSConfig_Panic 测试MustGetRustFSConfig在未初始化时panic
func TestMustGetRustFSConfig_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetRustFSConfig 应该在未初始化时panic")
}
}()
_ = MustGetRustFSConfig()
}
// TestInit_Once 测试Init只执行一次的逻辑
func TestInit_Once(t *testing.T) {
// 注意由于sync.Once的特性这个测试主要验证逻辑
// 实际测试中可能需要重置机制
// 验证Init函数可调用函数不能直接比较nil
// 这里只验证函数存在
_ = Init
}

View File

@@ -122,7 +122,11 @@ func (cm *CacheManager) Set(ctx context.Context, key string, value interface{},
// SetAsync 异步设置缓存,避免在主请求链路阻塞 // SetAsync 异步设置缓存,避免在主请求链路阻塞
func (cm *CacheManager) SetAsync(ctx context.Context, key string, value interface{}, expiration ...time.Duration) { func (cm *CacheManager) SetAsync(ctx context.Context, key string, value interface{}, expiration ...time.Duration) {
go func() { go func() {
_ = cm.Set(ctx, key, value, expiration...) // 使用独立 ctx 配合超时,防止上游 ctx 取消导致缓存写入失败,
// 同时避免 Redis 卡死时 goroutine 永久堆积
asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = cm.Set(asyncCtx, key, value, expiration...)
}() }()
} }

View File

@@ -2,64 +2,25 @@ package database
import ( import (
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/pkg/config"
"fmt" "fmt"
"sync"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
var ( // 本文件原包含基于 sync.Once 的全局单例Init/GetDB/MustGetDB/GetDBWrapper
// dbInstance 全局数据库实例 //
dbInstance *gorm.DB // 在 DI 迁移阶段4全局单例已被移除。数据库连接由 fx.Provide 构造
// once 确保只初始化一次 // (见 internal/app/infra_module.go 的 provideDatabase通过构造函数参数
once sync.Once // 注入 *database.DB 与 *gorm.DB 到各消费者。
// initError 初始化错误 //
initError error // 数据库构造逻辑见 postgres.go 的 New()。
)
// Init 初始化数据库连接(线程安全,只会执行一次)
func Init(cfg config.DatabaseConfig, logger *zap.Logger) error {
once.Do(func() {
dbInstance, initError = New(cfg)
if initError != nil {
logger.Error("数据库初始化失败", zap.Error(initError))
return
}
logger.Info("数据库连接成功")
})
return initError
}
// GetDB 获取数据库实例(线程安全)
func GetDB() (*gorm.DB, error) {
if dbInstance == nil {
return nil, fmt.Errorf("数据库未初始化,请先调用 database.Init()")
}
return dbInstance, nil
}
// MustGetDB 获取数据库实例如果未初始化则panic
func MustGetDB() *gorm.DB {
db, err := GetDB()
if err != nil {
panic(err)
}
return db
}
// AutoMigrate 自动迁移数据库表结构
func AutoMigrate(logger *zap.Logger) error {
db, err := GetDB()
if err != nil {
return fmt.Errorf("获取数据库实例失败: %w", err)
}
// AutoMigrateWithDB 使用指定的 *gorm.DB 执行表结构迁移(供 DI 使用)。
// 注意表的创建顺序:先创建被引用的表,再创建引用表。
func AutoMigrateWithDB(db *gorm.DB, logger *zap.Logger) error {
logger.Info("开始执行数据库迁移...") logger.Info("开始执行数据库迁移...")
// 迁移所有表 - 注意顺序:先创建被引用的表,再创建引用表
// 使用分批迁移,避免某些表的问题影响其他表
tables := []interface{}{ tables := []interface{}{
// 用户相关表(先创建,因为其他表可能引用它) // 用户相关表(先创建,因为其他表可能引用它)
&model.User{}, &model.User{},
@@ -87,7 +48,6 @@ func AutoMigrate(logger *zap.Logger) error {
&model.CasbinRule{}, &model.CasbinRule{},
} }
// 批量迁移表
if err := db.AutoMigrate(tables...); err != nil { if err := db.AutoMigrate(tables...); err != nil {
logger.Error("数据库迁移失败", zap.Error(err)) logger.Error("数据库迁移失败", zap.Error(err))
return fmt.Errorf("数据库迁移失败: %w", err) return fmt.Errorf("数据库迁移失败: %w", err)
@@ -96,17 +56,3 @@ func AutoMigrate(logger *zap.Logger) error {
logger.Info("数据库迁移完成") logger.Info("数据库迁移完成")
return nil return nil
} }
// Close 关闭数据库连接
func Close() error {
if dbInstance == nil {
return nil
}
sqlDB, err := dbInstance.DB()
if err != nil {
return err
}
return sqlDB.Close()
}

View File

@@ -8,17 +8,16 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// 使用内存 sqlite 验证 AutoMigrate 关键路径,无需真实 Postgres // 使用内存 sqlite 验证 AutoMigrateWithDB 关键路径,无需真实 Postgres
// 全局单例访问器Init/GetDB/MustGetDB已在 DI 迁移中移除。
func TestAutoMigrate_WithSQLite(t *testing.T) { func TestAutoMigrate_WithSQLite(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil { if err != nil {
t.Fatalf("open sqlite err: %v", err) t.Fatalf("open sqlite err: %v", err)
} }
dbInstance = db
defer func() { dbInstance = nil }()
logger := zaptest.NewLogger(t) logger := zaptest.NewLogger(t)
if err := AutoMigrate(logger); err != nil { if err := AutoMigrateWithDB(db, logger); err != nil {
t.Fatalf("AutoMigrate sqlite err: %v", err) t.Fatalf("AutoMigrateWithDB sqlite err: %v", err)
} }
} }

View File

@@ -1,87 +0,0 @@
package database
import (
"carrotskin/pkg/config"
"testing"
"go.uber.org/zap/zaptest"
)
// TestGetDB_NotInitialized 测试未初始化时获取数据库实例
func TestGetDB_NotInitialized(t *testing.T) {
dbInstance = nil
_, err := GetDB()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "数据库未初始化,请先调用 database.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetDB_Panic 测试MustGetDB在未初始化时panic
func TestMustGetDB_Panic(t *testing.T) {
dbInstance = nil
defer func() {
if r := recover(); r == nil {
t.Error("MustGetDB 应该在未初始化时panic")
}
}()
_ = MustGetDB()
}
// TestInit_Database 测试数据库初始化逻辑
func TestInit_Database(t *testing.T) {
dbInstance = nil
cfg := config.DatabaseConfig{
Driver: "postgres",
Host: "localhost",
Port: 5432,
Username: "postgres",
Password: "password",
Database: "testdb",
SSLMode: "disable",
Timezone: "Asia/Shanghai",
MaxIdleConns: 10,
MaxOpenConns: 100,
ConnMaxLifetime: 0,
}
logger := zaptest.NewLogger(t)
// 验证Init函数存在且可调用
// 注意:实际连接可能失败,这是可以接受的
err := Init(cfg, logger)
if err != nil {
t.Skipf("数据库未运行,跳过连接测试: %v", err)
}
}
// TestAutoMigrate_ErrorHandling 测试AutoMigrate的错误处理逻辑
func TestAutoMigrate_ErrorHandling(t *testing.T) {
logger := zaptest.NewLogger(t)
// 测试未初始化时的错误处理
err := AutoMigrate(logger)
if err == nil {
// 如果数据库已初始化,这是正常的
t.Log("AutoMigrate() 成功(数据库可能已初始化)")
} else {
// 如果数据库未初始化,应该返回错误
if err.Error() == "" {
t.Error("AutoMigrate() 应该返回有意义的错误消息")
}
}
}
// TestClose_NotInitialized 测试未初始化时关闭数据库
func TestClose_NotInitialized(t *testing.T) {
// 未初始化时关闭应该不返回错误
err := Close()
if err != nil {
t.Errorf("Close() 在未初始化时应该返回nil实际返回: %v", err)
}
}

View File

@@ -1,9 +1,12 @@
package database package database
import ( import (
"context"
"database/sql"
"fmt" "fmt"
"log" "log"
"os" "os"
"sync"
"time" "time"
"carrotskin/pkg/config" "carrotskin/pkg/config"
@@ -13,8 +16,31 @@ import (
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
// DBStats 数据库连接池统计信息
type DBStats struct {
MaxOpenConns int // 最大打开连接数
OpenConns int // 当前打开的连接数
InUseConns int // 正在使用的连接数
IdleConns int // 空闲连接数
WaitCount int64 // 等待连接的总次数
WaitDuration time.Duration // 等待连接的总时间
LastPingTime time.Time // 上次探活时间
LastPingSuccess bool // 上次探活是否成功
mu sync.RWMutex // 保护 LastPingTime 和 LastPingSuccess
}
// DB 数据库封装,包含连接池统计
type DB struct {
*gorm.DB
stats *DBStats
sqlDB *sql.DB
healthCh chan struct{} // 健康检查信号通道
closeCh chan struct{} // 关闭信号通道
wg sync.WaitGroup
}
// New 创建新的PostgreSQL数据库连接 // New 创建新的PostgreSQL数据库连接
func New(cfg config.DatabaseConfig) (*gorm.DB, error) { func New(cfg config.DatabaseConfig) (*DB, error) {
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s", dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s",
cfg.Host, cfg.Host,
cfg.Port, cfg.Port,
@@ -25,11 +51,11 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
cfg.Timezone, cfg.Timezone,
) )
// 配置慢查询监控 // 配置慢查询监控 - 优化从200ms调整为100ms
newLogger := logger.New( newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags), log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{ logger.Config{
SlowThreshold: 200 * time.Millisecond, // 慢查询阈值:200ms SlowThreshold: 100 * time.Millisecond, // 慢查询阈值:100ms(优化后)
LogLevel: logger.Warn, // 只记录警告和错误 LogLevel: logger.Warn, // 只记录警告和错误
IgnoreRecordNotFoundError: true, // 忽略记录未找到错误 IgnoreRecordNotFoundError: true, // 忽略记录未找到错误
Colorful: false, // 生产环境禁用彩色 Colorful: false, // 生产环境禁用彩色
@@ -79,12 +105,131 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
sqlDB.SetConnMaxLifetime(connMaxLifetime) sqlDB.SetConnMaxLifetime(connMaxLifetime)
sqlDB.SetConnMaxIdleTime(connMaxIdleTime) sqlDB.SetConnMaxIdleTime(connMaxIdleTime)
// 测试连接 // 测试连接(带重试机制)
if err := sqlDB.Ping(); err != nil { if err := pingWithRetry(sqlDB, 3, 2*time.Second); err != nil {
return nil, fmt.Errorf("数据库连接测试失败: %w", err) return nil, fmt.Errorf("数据库连接测试失败: %w", err)
} }
return db, nil // 创建数据库封装
database := &DB{
DB: db,
sqlDB: sqlDB,
stats: &DBStats{},
healthCh: make(chan struct{}, 1),
closeCh: make(chan struct{}),
}
// 初始化统计信息
database.updateStats()
// 启动定期健康检查
database.startHealthCheck(30 * time.Second)
log.Println("[Database] PostgreSQL连接池初始化成功")
log.Printf("[Database] 连接池配置: MaxIdleConns=%d, MaxOpenConns=%d, ConnMaxLifetime=%v, ConnMaxIdleTime=%v",
maxIdleConns, maxOpenConns, connMaxLifetime, connMaxIdleTime)
return database, nil
}
// pingWithRetry 带重试的Ping操作
func pingWithRetry(sqlDB *sql.DB, maxRetries int, retryInterval time.Duration) error {
var err error
for i := 0; i < maxRetries; i++ {
if err = sqlDB.Ping(); err == nil {
return nil
}
if i < maxRetries-1 {
log.Printf("[Database] Ping失败%v 后重试 (%d/%d): %v", retryInterval, i+1, maxRetries, err)
time.Sleep(retryInterval)
}
}
return err
}
// startHealthCheck 启动定期健康检查
func (d *DB) startHealthCheck(interval time.Duration) {
d.wg.Add(1)
go func() {
defer d.wg.Done()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
d.ping()
case <-d.healthCh:
d.ping()
case <-d.closeCh:
return
}
}
}()
}
// ping 执行连接健康检查
func (d *DB) ping() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := d.sqlDB.PingContext(ctx)
d.stats.mu.Lock()
d.stats.LastPingTime = time.Now()
d.stats.LastPingSuccess = err == nil
d.stats.mu.Unlock()
if err != nil {
log.Printf("[Database] 连接健康检查失败: %v", err)
} else {
log.Println("[Database] 连接健康检查成功")
}
}
// GetStats 获取连接池统计信息
func (d *DB) GetStats() DBStats {
d.stats.mu.RLock()
defer d.stats.mu.RUnlock()
// 从底层获取实时统计
stats := d.sqlDB.Stats()
d.stats.MaxOpenConns = stats.MaxOpenConnections
d.stats.OpenConns = stats.OpenConnections
d.stats.InUseConns = stats.InUse
d.stats.IdleConns = stats.Idle
d.stats.WaitCount = stats.WaitCount
d.stats.WaitDuration = stats.WaitDuration
return *d.stats
}
// updateStats 初始化统计信息
func (d *DB) updateStats() {
stats := d.sqlDB.Stats()
d.stats.MaxOpenConns = stats.MaxOpenConnections
d.stats.OpenConns = stats.OpenConnections
d.stats.InUseConns = stats.InUse
d.stats.IdleConns = stats.Idle
}
// LogStats 记录连接池状态日志
func (d *DB) LogStats() {
stats := d.GetStats()
log.Printf("[Database] 连接池状态: Open=%d, Idle=%d, InUse=%d, WaitCount=%d, WaitDuration=%v, LastPing=%v (%v)",
stats.OpenConns, stats.IdleConns, stats.InUseConns, stats.WaitCount, stats.WaitDuration,
stats.LastPingTime.Format("2006-01-02 15:04:05"), stats.LastPingSuccess)
}
// Close 关闭数据库连接
func (d *DB) Close() error {
close(d.closeCh)
d.wg.Wait()
return d.sqlDB.Close()
}
// WithTimeout 创建带有超时控制的上下文
func WithTimeout(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, timeout)
} }
// GetDSN 获取数据源名称 // GetDSN 获取数据源名称
@@ -99,9 +244,3 @@ func GetDSN(cfg config.DatabaseConfig) string {
cfg.Timezone, cfg.Timezone,
) )
} }

View File

@@ -50,13 +50,8 @@ var defaultCasbinRules = []model.CasbinRule{
{PType: "g", V0: "admin", V1: "user"}, {PType: "g", V0: "admin", V1: "user"},
} }
// Seed 初始化种子数据 // SeedWithDB 使用指定的 *gorm.DB 初始化种子数据(供 DI 使用)
func Seed(logger *zap.Logger) error { func SeedWithDB(db *gorm.DB, logger *zap.Logger) error {
db, err := GetDB()
if err != nil {
return err
}
logger.Info("开始初始化种子数据...") logger.Info("开始初始化种子数据...")
// 初始化默认管理员用户 // 初始化默认管理员用户

View File

@@ -3,13 +3,11 @@ package email
import ( import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net/smtp"
"net/textproto"
"carrotskin/pkg/config" "carrotskin/pkg/config"
"github.com/jordan-wright/email"
"go.uber.org/zap" "go.uber.org/zap"
gomail "gopkg.in/mail.v2"
) )
// Service 邮件服务 // Service 邮件服务
@@ -36,7 +34,7 @@ func (s *Service) SendVerificationCode(to, code, purpose string) error {
subject := s.getSubject(purpose) subject := s.getSubject(purpose)
body := s.getBody(code, purpose) body := s.getBody(code, purpose)
return s.send([]string{to}, subject, body) return s.send(to, subject, body)
} }
// SendResetPassword 发送重置密码邮件 // SendResetPassword 发送重置密码邮件
@@ -55,23 +53,13 @@ func (s *Service) SendChangeEmail(to, code string) error {
} }
// send 发送邮件 // send 发送邮件
func (s *Service) send(to []string, subject, body string) error { func (s *Service) send(to, subject, body string) error {
e := email.NewEmail() m := gomail.NewMessage()
e.From = fmt.Sprintf("%s <%s>", s.cfg.FromName, s.cfg.Username) m.SetHeader("From", fmt.Sprintf("%s <%s>", s.cfg.FromName, s.cfg.Username))
e.To = to m.SetHeader("To", to)
e.Subject = subject m.SetHeader("Subject", subject)
e.HTML = []byte(body) m.SetBody("text/html", body)
e.Headers = textproto.MIMEHeader{}
// SMTP认证
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.SMTPHost)
// 发送邮件
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, s.cfg.SMTPPort)
// 判断端口决定发送方式
// 465端口使用SSL/TLS隐式TLS
// 587端口使用STARTTLS显式TLS
var err error var err error
if s.cfg.SMTPPort == 465 { if s.cfg.SMTPPort == 465 {
// 使用SSL/TLS连接适用于465端口 // 使用SSL/TLS连接适用于465端口
@@ -79,15 +67,28 @@ func (s *Service) send(to []string, subject, body string) error {
ServerName: s.cfg.SMTPHost, ServerName: s.cfg.SMTPHost,
InsecureSkipVerify: false, // 生产环境建议设置为false InsecureSkipVerify: false, // 生产环境建议设置为false
} }
err = e.SendWithTLS(addr, auth, tlsConfig) d := &gomail.Dialer{
Host: s.cfg.SMTPHost,
Port: s.cfg.SMTPPort,
Username: s.cfg.Username,
Password: s.cfg.Password,
TLSConfig: tlsConfig,
}
err = d.DialAndSend(m)
} else { } else {
// 使用STARTTLS连接适用于587端口等 // 使用STARTTLS连接适用于587端口等
err = e.Send(addr, auth) d := &gomail.Dialer{
Host: s.cfg.SMTPHost,
Port: s.cfg.SMTPPort,
Username: s.cfg.Username,
Password: s.cfg.Password,
}
err = d.DialAndSend(m)
} }
if err != nil { if err != nil {
s.logger.Error("发送邮件失败", s.logger.Error("发送邮件失败",
zap.Strings("to", to), zap.String("to", to),
zap.String("subject", subject), zap.String("subject", subject),
zap.String("smtp_host", s.cfg.SMTPHost), zap.String("smtp_host", s.cfg.SMTPHost),
zap.Int("smtp_port", s.cfg.SMTPPort), zap.Int("smtp_port", s.cfg.SMTPPort),
@@ -97,7 +98,7 @@ func (s *Service) send(to []string, subject, body string) error {
} }
s.logger.Info("邮件发送成功", s.logger.Info("邮件发送成功",
zap.Strings("to", to), zap.String("to", to),
zap.String("subject", subject), zap.String("subject", subject),
) )

View File

@@ -2,7 +2,6 @@ package email
import ( import (
"strings" "strings"
"sync"
"testing" "testing"
"carrotskin/pkg/config" "carrotskin/pkg/config"
@@ -10,25 +9,18 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
func resetEmailOnce() { // 全局单例访问器Init/GetService/MustGetService已在 DI 迁移中移除。
serviceInstance = nil // 本测试改为直接使用构造函数 NewService。
once = sync.Once{}
}
func TestEmailManager_Disabled(t *testing.T) { func TestEmailManager_Disabled(t *testing.T) {
resetEmailOnce()
cfg := config.EmailConfig{Enabled: false} cfg := config.EmailConfig{Enabled: false}
if err := Init(cfg, zap.NewNop()); err != nil { svc := NewService(cfg, zap.NewNop())
t.Fatalf("Init disabled err: %v", err)
}
svc := MustGetService()
if err := svc.SendVerificationCode("to@test.com", "123456", "email_verification"); err == nil { if err := svc.SendVerificationCode("to@test.com", "123456", "email_verification"); err == nil {
t.Fatalf("expected error when disabled") t.Fatalf("expected error when disabled")
} }
} }
func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) { func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) {
resetEmailOnce()
cfg := config.EmailConfig{ cfg := config.EmailConfig{
Enabled: true, Enabled: true,
SMTPHost: "127.0.0.1", SMTPHost: "127.0.0.1",
@@ -37,8 +29,7 @@ func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) {
Password: "pwd", Password: "pwd",
FromName: "name", FromName: "name",
} }
_ = Init(cfg, zap.NewNop()) svc := NewService(cfg, zap.NewNop())
svc := MustGetService()
if err := svc.SendVerificationCode("to@test.com", "123456", "reset_password"); err == nil { if err := svc.SendVerificationCode("to@test.com", "123456", "reset_password"); err == nil {
t.Fatalf("expected send error with invalid smtp") t.Fatalf("expected send error with invalid smtp")
} }

View File

@@ -1,54 +1,8 @@
package email package email
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetService/MustGetService
"carrotskin/pkg/config" //
"fmt" // 在 DI 迁移阶段4全局单例已被移除。Email 服务由 fx.Provide(email.NewService)
"sync" // 构造,通过构造函数参数注入到各消费者。
//
"go.uber.org/zap" // 邮件服务构造逻辑见 email.go 的 NewService()。
)
var (
// serviceInstance 全局邮件服务实例
serviceInstance *Service
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
initError error
)
// Init 初始化邮件服务(线程安全,只会执行一次)
func Init(cfg config.EmailConfig, logger *zap.Logger) error {
once.Do(func() {
serviceInstance = NewService(cfg, logger)
})
return nil
}
// GetService 获取邮件服务实例(线程安全)
func GetService() (*Service, error) {
if serviceInstance == nil {
return nil, fmt.Errorf("邮件服务未初始化,请先调用 email.Init()")
}
return serviceInstance, nil
}
// MustGetService 获取邮件服务实例如果未初始化则panic
func MustGetService() *Service {
service, err := GetService()
if err != nil {
panic(err)
}
return service
}

View File

@@ -1,69 +0,0 @@
package email
import (
"carrotskin/pkg/config"
"sync"
"testing"
"go.uber.org/zap/zaptest"
)
func resetEmail() {
serviceInstance = nil
once = sync.Once{}
}
// TestGetService_NotInitialized 测试未初始化时获取邮件服务
func TestGetService_NotInitialized(t *testing.T) {
resetEmail()
_, err := GetService()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "邮件服务未初始化,请先调用 email.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetService_Panic 测试MustGetService在未初始化时panic
func TestMustGetService_Panic(t *testing.T) {
resetEmail()
defer func() {
if r := recover(); r == nil {
t.Error("MustGetService 应该在未初始化时panic")
}
}()
_ = MustGetService()
}
// TestInit_Email 测试邮件服务初始化
func TestInit_Email(t *testing.T) {
resetEmail()
cfg := config.EmailConfig{
Enabled: false,
SMTPHost: "smtp.example.com",
SMTPPort: 587,
Username: "user@example.com",
Password: "password",
FromName: "noreply@example.com",
}
logger := zaptest.NewLogger(t)
err := Init(cfg, logger)
if err != nil {
t.Errorf("Init() 错误 = %v, want nil", err)
}
// 验证可以获取服务
service, err := GetService()
if err != nil {
t.Errorf("GetService() 错误 = %v, want nil", err)
}
if service == nil {
t.Error("GetService() 返回的服务不应为nil")
}
}

View File

@@ -1,57 +1,8 @@
package logger package logger
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetLogger/MustGetLogger
"carrotskin/pkg/config" //
"fmt" // 在 DI 迁移阶段4全局单例已被移除。Logger 由 fx.Provide(logger.New)
"sync" // 构造,通过构造函数参数注入到各消费者。
//
"go.uber.org/zap" // 日志构造逻辑见 logger.go 的 New()。
)
var (
// loggerInstance 全局日志实例
loggerInstance *zap.Logger
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
initError error
)
// Init 初始化日志记录器(线程安全,只会执行一次)
func Init(cfg config.LogConfig) error {
once.Do(func() {
loggerInstance, initError = New(cfg)
if initError != nil {
return
}
})
return initError
}
// GetLogger 获取日志实例(线程安全)
func GetLogger() (*zap.Logger, error) {
if loggerInstance == nil {
return nil, fmt.Errorf("日志未初始化,请先调用 logger.Init()")
}
return loggerInstance, nil
}
// MustGetLogger 获取日志实例如果未初始化则panic
func MustGetLogger() *zap.Logger {
logger, err := GetLogger()
if err != nil {
panic(err)
}
return logger
}

View File

@@ -1,47 +0,0 @@
package logger
import (
"carrotskin/pkg/config"
"testing"
)
// TestGetLogger_NotInitialized 测试未初始化时获取日志实例
func TestGetLogger_NotInitialized(t *testing.T) {
_, err := GetLogger()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "日志未初始化,请先调用 logger.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetLogger_Panic 测试MustGetLogger在未初始化时panic
func TestMustGetLogger_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetLogger 应该在未初始化时panic")
}
}()
_ = MustGetLogger()
}
// TestInit_Logger 测试日志初始化逻辑
func TestInit_Logger(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Format: "json",
Output: "stdout",
}
// 验证Init函数存在且可调用
err := Init(cfg)
if err != nil {
// 初始化可能失败(例如缺少依赖),这是可以接受的
t.Logf("Init() 返回错误(可能正常): %v", err)
}
}

View File

@@ -1,75 +1,38 @@
package redis package redis
import ( import (
"carrotskin/pkg/config"
"fmt" "fmt"
"os" "os"
"sync"
"github.com/alicebob/miniredis/v2" "github.com/alicebob/miniredis/v2"
redis9 "github.com/redis/go-redis/v9" redis9 "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
) )
var ( // 本文件原包含基于 sync.Once 的全局单例Init/GetClient/MustGetClient/Close
// clientInstance 全局Redis客户端实例 //
clientInstance *Client // 在 DI 迁移阶段4全局单例已被移除。Redis 客户端由 fx.Provide 构造
// once 确保只初始化一次 // (见 internal/app/infra_module.go 的 provideRedis包含 miniredis 回退逻辑),
once sync.Once // 通过构造函数参数注入到各消费者。
// initError 初始化错误 //
initError error // Redis 客户端构造逻辑见 redis.go 的 New()。
// miniredisInstance 用于测试/开发环境
miniredisInstance *miniredis.Miniredis
)
// Init 初始化Redis客户端线程安全只会执行一次 // AllowFallbackToMiniRedis 检查当前环境是否允许回退到 miniredis仅开发/测试环境)。
// 如果Redis连接失败且环境为测试/开发则回退到miniredis func AllowFallbackToMiniRedis() bool {
func Init(cfg config.RedisConfig, logger *zap.Logger) error {
var err error
once.Do(func() {
// 尝试连接真实Redis
clientInstance, err = New(cfg, logger)
if err != nil {
logger.Warn("Redis连接失败尝试使用miniredis回退", zap.Error(err))
// 检查是否允许回退到miniredis仅开发/测试环境)
if allowFallbackToMiniRedis() {
clientInstance, err = initMiniRedis(logger)
if err != nil {
initError = fmt.Errorf("Redis和miniredis都初始化失败: %w", err)
logger.Error("miniredis初始化失败", zap.Error(initError))
return
}
logger.Info("已回退到miniredis用于开发/测试环境")
} else {
initError = fmt.Errorf("Redis连接失败且不允许回退: %w", err)
logger.Error("Redis连接失败", zap.Error(initError))
return
}
}
})
return initError
}
// allowFallbackToMiniRedis 检查是否允许回退到miniredis
func allowFallbackToMiniRedis() bool {
// 检查环境变量
env := os.Getenv("ENVIRONMENT") env := os.Getenv("ENVIRONMENT")
return env == "development" || env == "test" || env == "dev" || return env == "development" || env == "test" || env == "dev" ||
os.Getenv("USE_MINIREDIS") == "true" os.Getenv("USE_MINIREDIS") == "true"
} }
// initMiniRedis 初始化miniredis用于开发/测试环境 // InitMiniRedis 初始化 miniredis用于开发/测试环境的 DI 回退)。
func initMiniRedis(logger *zap.Logger) (*Client, error) { func InitMiniRedis(logger *zap.Logger) (*Client, error) {
var err error mr, err := miniredis.Run()
miniredisInstance, err = miniredis.Run()
if err != nil { if err != nil {
return nil, fmt.Errorf("启动miniredis失败: %w", err) return nil, fmt.Errorf("启动miniredis失败: %w", err)
} }
// 创建Redis客户端连接到miniredis
redisClient := redis9.NewClient(&redis9.Options{ redisClient := redis9.NewClient(&redis9.Options{
Addr: miniredisInstance.Addr(), Addr: mr.Addr(),
}) })
client := &Client{ client := &Client{
@@ -77,42 +40,6 @@ func initMiniRedis(logger *zap.Logger) (*Client, error) {
logger: logger, logger: logger,
} }
logger.Info("miniredis已启动", zap.String("addr", miniredisInstance.Addr())) logger.Info("miniredis已启动", zap.String("addr", mr.Addr()))
return client, nil return client, nil
} }
// GetClient 获取Redis客户端实例线程安全
func GetClient() (*Client, error) {
if clientInstance == nil {
return nil, fmt.Errorf("Redis客户端未初始化请先调用 redis.Init()")
}
return clientInstance, nil
}
// MustGetClient 获取Redis客户端实例如果未初始化则panic
func MustGetClient() *Client {
client, err := GetClient()
if err != nil {
panic(err)
}
return client
}
// Close 关闭Redis连接包括miniredis如果使用了
func Close() error {
var err error
if miniredisInstance != nil {
miniredisInstance.Close()
miniredisInstance = nil
}
if clientInstance != nil {
err = clientInstance.Close()
clientInstance = nil
}
return err
}
// IsUsingMiniRedis 检查是否使用了miniredis
func IsUsingMiniRedis() bool {
return miniredisInstance != nil
}

View File

@@ -1,53 +0,0 @@
package redis
import (
"carrotskin/pkg/config"
"testing"
"go.uber.org/zap/zaptest"
)
// TestGetClient_NotInitialized 测试未初始化时获取Redis客户端
func TestGetClient_NotInitialized(t *testing.T) {
_, err := GetClient()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "Redis客户端未初始化请先调用 redis.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetClient_Panic 测试MustGetClient在未初始化时panic
func TestMustGetClient_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetClient 应该在未初始化时panic")
}
}()
_ = MustGetClient()
}
// TestInit_Redis 测试Redis初始化逻辑
func TestInit_Redis(t *testing.T) {
cfg := config.RedisConfig{
Host: "localhost",
Port: 6379,
Password: "",
Database: 0,
PoolSize: 10,
}
logger := zaptest.NewLogger(t)
// 验证Init函数存在且可调用
// 注意:实际连接可能失败,这是可以接受的
err := Init(cfg, logger)
if err != nil {
t.Logf("Init() 返回错误可能正常如果Redis未运行: %v", err)
}
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sync"
"time" "time"
"carrotskin/pkg/config" "carrotskin/pkg/config"
@@ -12,23 +13,39 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// Client Redis客户端包装 // Client Redis客户端包装(包含连接池统计和健康检查)
type Client struct { type Client struct {
*redis.Client *redis.Client // 嵌入原始Redis客户端
logger *zap.Logger logger *zap.Logger // 日志记录器
stats *RedisStats // 连接池统计信息
healthCheckDone chan struct{} // 健康检查完成信号
closeCh chan struct{} // 关闭信号通道
wg sync.WaitGroup // 等待组
} }
// New 创建Redis客户端 // RedisStats Redis连接池统计信息
type RedisStats struct {
PoolSize int // 连接池大小
IdleConns int // 空闲连接数
ActiveConns int // 活跃连接数
StaleConns int // 过期连接数
TotalConns int // 总连接数
LastPingTime time.Time // 上次探活时间
LastPingSuccess bool // 上次探活是否成功
mu sync.RWMutex // 保护统计信息
}
// New 创建Redis客户端带健康检查和优化配置
func New(cfg config.RedisConfig, logger *zap.Logger) (*Client, error) { func New(cfg config.RedisConfig, logger *zap.Logger) (*Client, error) {
// 设置默认值 // 设置默认值
poolSize := cfg.PoolSize poolSize := cfg.PoolSize
if poolSize <= 0 { if poolSize <= 0 {
poolSize = 10 poolSize = 16 // 优化:提高默认连接池大小
} }
minIdleConns := cfg.MinIdleConns minIdleConns := cfg.MinIdleConns
if minIdleConns <= 0 { if minIdleConns <= 0 {
minIdleConns = 5 minIdleConns = 8 // 优化:提高最小空闲连接数
} }
maxRetries := cfg.MaxRetries maxRetries := cfg.MaxRetries
@@ -58,10 +75,15 @@ func New(cfg config.RedisConfig, logger *zap.Logger) (*Client, error) {
connMaxIdleTime := cfg.ConnMaxIdleTime connMaxIdleTime := cfg.ConnMaxIdleTime
if connMaxIdleTime <= 0 { if connMaxIdleTime <= 0 {
connMaxIdleTime = 30 * time.Minute connMaxIdleTime = 10 * time.Minute // 优化:减少空闲连接超时
} }
// 创建Redis客户端 connMaxLifetime := cfg.ConnMaxLifetime
if connMaxLifetime <= 0 {
connMaxLifetime = 30 * time.Minute // 新增:连接最大生命周期
}
// 创建Redis客户端带优化配置
rdb := redis.NewClient(&redis.Options{ rdb := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Password: cfg.Password, Password: cfg.Password,
@@ -74,125 +96,254 @@ func New(cfg config.RedisConfig, logger *zap.Logger) (*Client, error) {
WriteTimeout: writeTimeout, WriteTimeout: writeTimeout,
PoolTimeout: poolTimeout, PoolTimeout: poolTimeout,
ConnMaxIdleTime: connMaxIdleTime, ConnMaxIdleTime: connMaxIdleTime,
ConnMaxLifetime: connMaxLifetime,
}) })
// 测试连接 // 测试连接(带重试机制)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if err := pingWithRetry(rdb, 3, 2*time.Second); err != nil {
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("Redis连接失败: %w", err) return nil, fmt.Errorf("Redis连接失败: %w", err)
} }
// 创建客户端包装
client := &Client{
Client: rdb,
logger: logger,
stats: &RedisStats{},
healthCheckDone: make(chan struct{}),
closeCh: make(chan struct{}),
}
// 初始化统计信息
client.updateStats()
// 启动定期健康检查
healthCheckInterval := cfg.HealthCheckInterval
if healthCheckInterval <= 0 {
healthCheckInterval = 30 * time.Second
}
client.startHealthCheck(healthCheckInterval)
logger.Info("Redis连接成功", logger.Info("Redis连接成功",
zap.String("host", cfg.Host), zap.String("host", cfg.Host),
zap.Int("port", cfg.Port), zap.Int("port", cfg.Port),
zap.Int("database", cfg.Database), zap.Int("database", cfg.Database),
zap.Int("pool_size", poolSize),
zap.Int("min_idle_conns", minIdleConns),
) )
return &Client{ return client, nil
Client: rdb, }
logger: logger,
}, nil // pingWithRetry 带重试的Ping操作
func pingWithRetry(rdb *redis.Client, maxRetries int, retryInterval time.Duration) error {
var err error
for i := 0; i < maxRetries; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err = rdb.Ping(ctx).Err()
cancel()
if err == nil {
return nil
}
if i < maxRetries-1 {
time.Sleep(retryInterval)
}
}
return err
}
// startHealthCheck 启动定期健康检查
func (c *Client) startHealthCheck(interval time.Duration) {
c.wg.Add(1)
go func() {
defer c.wg.Done()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.doHealthCheck()
case <-c.closeCh:
return
}
}
}()
}
// doHealthCheck 执行健康检查
func (c *Client) doHealthCheck() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 更新统计信息
c.updateStats()
// 执行Ping检查
err := c.Client.Ping(ctx).Err()
c.stats.mu.Lock()
c.stats.LastPingTime = time.Now()
c.stats.LastPingSuccess = err == nil
c.stats.mu.Unlock()
if err != nil {
c.logger.Warn("Redis健康检查失败", zap.Error(err))
} else {
c.logger.Debug("Redis健康检查成功")
}
}
// updateStats 更新连接池统计信息
func (c *Client) updateStats() {
// 获取底层连接池统计信息
stats := c.Client.PoolStats()
c.stats.mu.Lock()
c.stats.PoolSize = c.Client.Options().PoolSize
c.stats.IdleConns = int(stats.IdleConns)
c.stats.ActiveConns = int(stats.TotalConns) - int(stats.IdleConns)
c.stats.TotalConns = int(stats.TotalConns)
c.stats.StaleConns = int(stats.StaleConns)
c.stats.mu.Unlock()
}
// GetStats 获取连接池统计信息
func (c *Client) GetStats() RedisStats {
c.stats.mu.RLock()
defer c.stats.mu.RUnlock()
return RedisStats{
PoolSize: c.stats.PoolSize,
IdleConns: c.stats.IdleConns,
ActiveConns: c.stats.ActiveConns,
StaleConns: c.stats.StaleConns,
TotalConns: c.stats.TotalConns,
LastPingTime: c.stats.LastPingTime,
LastPingSuccess: c.stats.LastPingSuccess,
}
}
// LogStats 记录连接池状态日志
func (c *Client) LogStats() {
stats := c.GetStats()
c.logger.Info("Redis连接池状态",
zap.Int("pool_size", stats.PoolSize),
zap.Int("idle_conns", stats.IdleConns),
zap.Int("active_conns", stats.ActiveConns),
zap.Int("total_conns", stats.TotalConns),
zap.Int("stale_conns", stats.StaleConns),
zap.Bool("last_ping_success", stats.LastPingSuccess),
)
}
// Ping 验证Redis连接带超时控制
func (c *Client) Ping(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return c.Client.Ping(ctx).Err()
} }
// Close 关闭Redis连接 // Close 关闭Redis连接
func (c *Client) Close() error { func (c *Client) Close() error {
// 停止健康检查
close(c.closeCh)
c.wg.Wait()
c.logger.Info("正在关闭Redis连接") c.logger.Info("正在关闭Redis连接")
c.LogStats() // 关闭前记录最终状态
return c.Client.Close() return c.Client.Close()
} }
// Set 设置键值对(带过期时间) // ===== 以下是封装的便捷方法,用于返回 (value, error) 格式 =====
// Set 设置键值对(带过期时间)- 封装版本
func (c *Client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { func (c *Client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return c.Client.Set(ctx, key, value, expiration).Err() return c.Client.Set(ctx, key, value, expiration).Err()
} }
// Get 获取键值 // Get 获取键值 - 封装版本
func (c *Client) Get(ctx context.Context, key string) (string, error) { func (c *Client) Get(ctx context.Context, key string) (string, error) {
return c.Client.Get(ctx, key).Result() return c.Client.Get(ctx, key).Result()
} }
// Del 删除键 // Del 删除键 - 封装版本
func (c *Client) Del(ctx context.Context, keys ...string) error { func (c *Client) Del(ctx context.Context, keys ...string) error {
return c.Client.Del(ctx, keys...).Err() return c.Client.Del(ctx, keys...).Err()
} }
// Exists 检查键是否存在 // Exists 检查键是否存在 - 封装版本
func (c *Client) Exists(ctx context.Context, keys ...string) (int64, error) { func (c *Client) Exists(ctx context.Context, keys ...string) (int64, error) {
return c.Client.Exists(ctx, keys...).Result() return c.Client.Exists(ctx, keys...).Result()
} }
// Expire 设置键的过期时间 // Expire 设置键的过期时间 - 封装版本
func (c *Client) Expire(ctx context.Context, key string, expiration time.Duration) error { func (c *Client) Expire(ctx context.Context, key string, expiration time.Duration) error {
return c.Client.Expire(ctx, key, expiration).Err() return c.Client.Expire(ctx, key, expiration).Err()
} }
// TTL 获取键的剩余过期时间 // TTL 获取键的剩余过期时间 - 封装版本
func (c *Client) TTL(ctx context.Context, key string) (time.Duration, error) { func (c *Client) TTL(ctx context.Context, key string) (time.Duration, error) {
return c.Client.TTL(ctx, key).Result() return c.Client.TTL(ctx, key).Result()
} }
// Incr 自增 // Incr 自增 - 封装版本
func (c *Client) Incr(ctx context.Context, key string) (int64, error) { func (c *Client) Incr(ctx context.Context, key string) (int64, error) {
return c.Client.Incr(ctx, key).Result() return c.Client.Incr(ctx, key).Result()
} }
// Decr 自减 // Decr 自减 - 封装版本
func (c *Client) Decr(ctx context.Context, key string) (int64, error) { func (c *Client) Decr(ctx context.Context, key string) (int64, error) {
return c.Client.Decr(ctx, key).Result() return c.Client.Decr(ctx, key).Result()
} }
// HSet 设置哈希字段 // HSet 设置哈希字段 - 封装版本
func (c *Client) HSet(ctx context.Context, key string, values ...interface{}) error { func (c *Client) HSet(ctx context.Context, key string, values ...interface{}) error {
return c.Client.HSet(ctx, key, values...).Err() return c.Client.HSet(ctx, key, values...).Err()
} }
// HGet 获取哈希字段 // HGet 获取哈希字段 - 封装版本
func (c *Client) HGet(ctx context.Context, key, field string) (string, error) { func (c *Client) HGet(ctx context.Context, key, field string) (string, error) {
return c.Client.HGet(ctx, key, field).Result() return c.Client.HGet(ctx, key, field).Result()
} }
// HGetAll 获取所有哈希字段 // HGetAll 获取所有哈希字段 - 封装版本
func (c *Client) HGetAll(ctx context.Context, key string) (map[string]string, error) { func (c *Client) HGetAll(ctx context.Context, key string) (map[string]string, error) {
return c.Client.HGetAll(ctx, key).Result() return c.Client.HGetAll(ctx, key).Result()
} }
// HDel 删除哈希字段 // HDel 删除哈希字段 - 封装版本
func (c *Client) HDel(ctx context.Context, key string, fields ...string) error { func (c *Client) HDel(ctx context.Context, key string, fields ...string) error {
return c.Client.HDel(ctx, key, fields...).Err() return c.Client.HDel(ctx, key, fields...).Err()
} }
// SAdd 添加集合成员 // SAdd 添加集合成员 - 封装版本
func (c *Client) SAdd(ctx context.Context, key string, members ...interface{}) error { func (c *Client) SAdd(ctx context.Context, key string, members ...interface{}) error {
return c.Client.SAdd(ctx, key, members...).Err() return c.Client.SAdd(ctx, key, members...).Err()
} }
// SMembers 获取集合所有成员 // SMembers 获取集合所有成员 - 封装版本
func (c *Client) SMembers(ctx context.Context, key string) ([]string, error) { func (c *Client) SMembers(ctx context.Context, key string) ([]string, error) {
return c.Client.SMembers(ctx, key).Result() return c.Client.SMembers(ctx, key).Result()
} }
// SRem 删除集合成员 // SRem 删除集合成员 - 封装版本
func (c *Client) SRem(ctx context.Context, key string, members ...interface{}) error { func (c *Client) SRem(ctx context.Context, key string, members ...interface{}) error {
return c.Client.SRem(ctx, key, members...).Err() return c.Client.SRem(ctx, key, members...).Err()
} }
// SIsMember 检查是否是集合成员 // SIsMember 检查是否是集合成员 - 封装版本
func (c *Client) SIsMember(ctx context.Context, key string, member interface{}) (bool, error) { func (c *Client) SIsMember(ctx context.Context, key string, member interface{}) (bool, error) {
return c.Client.SIsMember(ctx, key, member).Result() return c.Client.SIsMember(ctx, key, member).Result()
} }
// ZAdd 添加有序集合成员 // ZAdd 添加有序集合成员 - 封装版本
func (c *Client) ZAdd(ctx context.Context, key string, members ...redis.Z) error { func (c *Client) ZAdd(ctx context.Context, key string, members ...redis.Z) error {
return c.Client.ZAdd(ctx, key, members...).Err() return c.Client.ZAdd(ctx, key, members...).Err()
} }
// ZRange 获取有序集合范围内的成员 // ZRange 获取有序集合范围内的成员 - 封装版本
func (c *Client) ZRange(ctx context.Context, key string, start, stop int64) ([]string, error) { func (c *Client) ZRange(ctx context.Context, key string, start, stop int64) ([]string, error) {
return c.Client.ZRange(ctx, key, start, stop).Result() return c.Client.ZRange(ctx, key, start, stop).Result()
} }
// ZRem 删除有序集合成员 // ZRem 删除有序集合成员 - 封装版本
func (c *Client) ZRem(ctx context.Context, key string, members ...interface{}) error { func (c *Client) ZRem(ctx context.Context, key string, members ...interface{}) error {
return c.Client.ZRem(ctx, key, members...).Err() return c.Client.ZRem(ctx, key, members...).Err()
} }
@@ -207,6 +358,7 @@ func (c *Client) TxPipeline() redis.Pipeliner {
return c.Client.TxPipeline() return c.Client.TxPipeline()
} }
// Nil 检查错误是否为Nilkey不存在
func (c *Client) Nil(err error) bool { func (c *Client) Nil(err error) bool {
return errors.Is(err, redis.Nil) return errors.Is(err, redis.Nil)
} }

View File

@@ -1,55 +1,8 @@
package storage package storage
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetClient/MustGetClient
"carrotskin/pkg/config" //
"fmt" // 在 DI 迁移阶段4全局单例已被移除。Storage 客户端由 fx.Provide(storage.NewStorage)
"sync" // 构造,通过构造函数参数注入到各消费者。
) //
// 存储客户端构造逻辑见 minio.go 的 NewStorage()。
var (
// clientInstance 全局存储客户端实例
clientInstance *StorageClient
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
initError error
)
// Init 初始化存储客户端(线程安全,只会执行一次)
func Init(cfg config.RustFSConfig) error {
once.Do(func() {
clientInstance, initError = NewStorage(cfg)
if initError != nil {
return
}
})
return initError
}
// GetClient 获取存储客户端实例(线程安全)
func GetClient() (*StorageClient, error) {
if clientInstance == nil {
return nil, fmt.Errorf("存储客户端未初始化,请先调用 storage.Init()")
}
return clientInstance, nil
}
// MustGetClient 获取存储客户端实例如果未初始化则panic
func MustGetClient() *StorageClient {
client, err := GetClient()
if err != nil {
panic(err)
}
return client
}

View File

@@ -1,52 +0,0 @@
package storage
import (
"carrotskin/pkg/config"
"testing"
)
// TestGetClient_NotInitialized 测试未初始化时获取存储客户端
func TestGetClient_NotInitialized(t *testing.T) {
_, err := GetClient()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "存储客户端未初始化,请先调用 storage.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetClient_Panic 测试MustGetClient在未初始化时panic
func TestMustGetClient_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetClient 应该在未初始化时panic")
}
}()
_ = MustGetClient()
}
// TestInit_Storage 测试存储客户端初始化逻辑
func TestInit_Storage(t *testing.T) {
cfg := config.RustFSConfig{
Endpoint: "http://localhost:9000",
AccessKey: "minioadmin",
SecretKey: "minioadmin",
UseSSL: false,
Buckets: map[string]string{
"avatars": "avatars",
"textures": "textures",
},
}
// 验证Init函数存在且可调用
// 注意:实际连接可能失败,这是可以接受的
err := Init(cfg)
if err != nil {
t.Logf("Init() 返回错误(可能正常,如果存储服务未运行): %v", err)
}
}

View File

@@ -1,8 +1,12 @@
package utils package utils
import ( import (
"go.uber.org/zap" "crypto/rand"
"encoding/hex"
"strings" "strings"
"github.com/google/uuid"
"go.uber.org/zap"
) )
// FormatUUID 将UUID格式化为带连字符的标准格式 // FormatUUID 将UUID格式化为带连字符的标准格式
@@ -45,3 +49,49 @@ func FormatUUID(uuid string) string {
logger.Warn("[WARN] UUID格式无效: ", zap.String("uuid:", uuid)) logger.Warn("[WARN] UUID格式无效: ", zap.String("uuid:", uuid))
return uuid return uuid
} }
// GenerateUUID 生成无符号UUID32位十六进制字符串不带连字符
// 使用github.com/google/uuid库生成标准UUID然后移除连字符
// 返回格式示例: "123e4567e89b12d3a456426614174000"
func GenerateUUID() string {
return strings.ReplaceAll(uuid.New().String(), "-", "")
}
// FormatUUIDToNoDash 将带连字符的UUID转换为无符号格式移除连字符
// 输入: "123e4567-e89b-12d3-a456-426614174000"
// 输出: "123e4567e89b12d3a456426614174000"
// 如果输入已经是32位格式直接返回
// 如果输入格式无效,返回原值并记录警告
func FormatUUIDToNoDash(uuid string) string {
// 如果为空,直接返回
if uuid == "" {
return uuid
}
// 如果已经是32位格式无连字符直接返回
if len(uuid) == 32 {
return uuid
}
// 如果是36位标准格式移除连字符
if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' {
return strings.ReplaceAll(uuid, "-", "")
}
// 如果格式无效,记录警告并返回原值
var logger *zap.Logger
logger.Warn("[WARN] UUID格式无效无法转换为无符号格式: ", zap.String("uuid:", uuid))
return uuid
}
// RandomHex 生成指定长度的随机十六进制字符串
// 参数 n: 需要生成的十六进制字符数量每个字节生成2个十六进制字符
// 返回: 长度为 2*n 的十六进制字符串
// 示例: RandomHex(16) 返回 "a1b2c3d4e5f67890abcdef1234567890" (32字符)
func RandomHex(n uint) (string, error) {
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

View File

@@ -241,7 +241,7 @@ curl -X POST http://localhost:8080/api/v1/user/yggdrasil-password/reset \\
-H 'Authorization: Bearer {user_info['jwt_token']}' -H 'Authorization: Bearer {user_info['jwt_token']}'
# 4. Yggdrasil认证 # 4. Yggdrasil认证
curl -X POST http://localhost:8080/api/v1/yggdrasil/authserver/authenticate \\ curl -X POST http://localhost:8080/api/yggdrasil/authserver/authenticate \\
-H 'Content-Type: application/json' \\ -H 'Content-Type: application/json' \\
-d '{{ -d '{{
"username": "{user_info['username']}", "username": "{user_info['username']}",