refactor: 移除全局单例、修复分层违规、统一错误与响应
Some checks failed
Build / build (push) Successful in 7m52s
Build / build-docker (push) Has been cancelled

This commit is contained in:
lafay
2026-06-15 16:40:36 +08:00
parent d9de39a0a3
commit 7d1c78f965
55 changed files with 593 additions and 1744 deletions

View File

@@ -4,10 +4,12 @@ import (
"carrotskin/internal/repository"
"carrotskin/internal/service"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/email"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"context"
"time"
"go.uber.org/zap"
@@ -17,6 +19,9 @@ import (
// Container 依赖注入容器
// 集中管理所有依赖,便于测试和维护
type Container struct {
// 配置
Config *config.Config
// 基础设施依赖
DB *gorm.DB
Redis *redis.Client
@@ -40,20 +45,20 @@ type Container struct {
TokenService service.TokenService
YggdrasilService service.YggdrasilService
VerificationService service.VerificationService
SecurityService service.SecurityService
CaptchaService service.CaptchaService
SignatureService *service.SignatureService
}
// NewContainer 创建依赖容器
func NewContainer(
cfg *config.Config,
db *gorm.DB,
redisClient *redis.Client,
logger *zap.Logger,
jwtService *auth.JWTService,
casbinService *auth.CasbinService,
storageClient *storage.StorageClient,
emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖
emailService *email.Service,
) *Container {
// 创建缓存管理器
cacheManager := database.NewCacheManager(redisClient, database.CacheConfig{
@@ -71,6 +76,7 @@ func NewContainer(
})
c := &Container{
Config: cfg,
DB: db,
Redis: redisClient,
Logger: logger,
@@ -92,14 +98,17 @@ func NewContainer(
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
// 初始化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.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需要
// 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT
// 但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 {
logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err))
}
@@ -121,149 +130,15 @@ func NewContainer(
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(redisClient, logger)
c.CaptchaService = service.NewCaptchaService(cfg, redisClient, logger)
// 初始化VerificationService需要email.Service
if emailService != nil {
if emailSvc, ok := emailService.(*email.Service); ok {
c.VerificationService = service.NewVerificationService(redisClient, emailSvc)
}
c.VerificationService = service.NewVerificationService(cfg, redisClient, emailService)
}
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

@@ -125,23 +125,9 @@ func NewInternalError(message string, err error) *AppError {
return NewAppError(500, message, err)
}
// Is 检查错误是否匹配
func Is(err, target error) bool {
return errors.Is(err, target)
}
// 注意:原此处的 Is/As/Wrap 函数(透传标准库)已删除。
// 请直接使用标准库 errors.Is / errors.As / fmt.Errorf。
// As 尝试将错误转换为指定类型
func As(err error, target interface{}) bool {
return errors.As(err, target)
}
// Wrap 包装错误
func Wrap(err error, message string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", message, err)
}
// YggdrasilErrorResponse Yggdrasil协议标准错误响应格式
type YggdrasilErrorResponse struct {

View File

@@ -15,24 +15,12 @@ func TestAppErrorBasics(t *testing.T) {
if got := appErr.Error(); got != "bad: root" {
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")
}
var target *AppError
if !As(appErr, &target) {
if !errors.As(appErr, &target) {
t.Fatalf("As should succeed")
}
}
func TestWrap(t *testing.T) {
if Wrap(nil, "msg") != nil {
t.Fatalf("Wrap nil should return nil")
}
err := errors.New("base")
wrapped := Wrap(err, "ctx")
if wrapped.Error() != "ctx: base" {
t.Fatalf("wrap message mismatch: %v", wrapped)
}
}

View File

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

View File

@@ -4,7 +4,6 @@ import (
"carrotskin/internal/container"
"carrotskin/internal/service"
"carrotskin/internal/types"
"carrotskin/pkg/email"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -177,8 +176,3 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
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 (
"carrotskin/internal/container"
"context"
"fmt"
"net/http"
"strings"
@@ -233,9 +234,12 @@ func (h *CustomSkinHandler) GetTexture(c *gin.Context) {
}
}
// 增加下载计数(异步)
// 增加下载计数(异步,使用独立 ctx 避免请求取消时丢失计数
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
import (
"carrotskin/internal/errors"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/types"
"errors"
"net/http"
"strconv"
@@ -190,31 +191,31 @@ func RespondWithError(c *gin.Context, err error) {
return
}
// 使用errors.Is检查预定义错误
if errors.Is(err, errors.ErrUserNotFound) ||
errors.Is(err, errors.ErrProfileNotFound) ||
errors.Is(err, errors.ErrTextureNotFound) ||
errors.Is(err, errors.ErrNotFound) {
// 使用标准库 errors.Is 检查预定义错误
if errors.Is(err, apperrors.ErrUserNotFound) ||
errors.Is(err, apperrors.ErrProfileNotFound) ||
errors.Is(err, apperrors.ErrTextureNotFound) ||
errors.Is(err, apperrors.ErrNotFound) {
RespondNotFound(c, err.Error())
return
}
if errors.Is(err, errors.ErrProfileNoPermission) ||
errors.Is(err, errors.ErrTextureNoPermission) ||
errors.Is(err, errors.ErrForbidden) {
if errors.Is(err, apperrors.ErrProfileNoPermission) ||
errors.Is(err, apperrors.ErrTextureNoPermission) ||
errors.Is(err, apperrors.ErrForbidden) {
RespondForbidden(c, err.Error())
return
}
if errors.Is(err, errors.ErrUnauthorized) ||
errors.Is(err, errors.ErrInvalidToken) ||
errors.Is(err, errors.ErrTokenExpired) {
if errors.Is(err, apperrors.ErrUnauthorized) ||
errors.Is(err, apperrors.ErrInvalidToken) ||
errors.Is(err, apperrors.ErrTokenExpired) {
RespondUnauthorized(c, err.Error())
return
}
// 检查AppError类型
var appErr *errors.AppError
var appErr *apperrors.AppError
if errors.As(err, &appErr) {
c.JSON(appErr.Code, model.NewErrorResponse(
appErr.Code,

View File

@@ -4,7 +4,6 @@ import (
"carrotskin/internal/container"
"carrotskin/internal/middleware"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
@@ -40,11 +39,10 @@ func NewHandlers(c *container.Container) *Handlers {
// RegisterRoutesWithDI 使用依赖注入注册所有路由
func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 健康检查路由
router.GET("/health", HealthCheck)
router.GET("/health", NewHealthCheck(c.DB, c.Redis))
// Swagger文档路由
cfg, _ := config.GetConfig()
if cfg != nil && cfg.Server.SwaggerEnabled {
if c.Config != nil && c.Config.Server.SwaggerEnabled {
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}

View File

@@ -6,14 +6,15 @@ import (
"net/http"
"time"
"carrotskin/pkg/database"
"carrotskin/pkg/redis"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// HealthCheck 健康检查,检查依赖服务状态
func HealthCheck(c *gin.Context) {
// NewHealthCheck 构造健康检查 handler依赖通过参数注入。
func NewHealthCheck(db *gorm.DB, redisClient *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
@@ -21,7 +22,7 @@ func HealthCheck(c *gin.Context) {
status := "ok"
// 检查数据库
if err := checkDatabase(ctx); err != nil {
if err := checkDatabase(ctx, db); err != nil {
checks["database"] = "unhealthy: " + err.Error()
status = "degraded"
} else {
@@ -29,7 +30,7 @@ func HealthCheck(c *gin.Context) {
}
// 检查Redis
if err := checkRedis(ctx); err != nil {
if err := checkRedis(ctx, redisClient); err != nil {
checks["redis"] = "unhealthy: " + err.Error()
status = "degraded"
} else {
@@ -48,15 +49,14 @@ func HealthCheck(c *gin.Context) {
"checks": checks,
"timestamp": time.Now().Unix(),
})
}
}
// checkDatabase 检查数据库连接
func checkDatabase(ctx context.Context) error {
db, err := database.GetDB()
if err != nil {
return err
func checkDatabase(ctx context.Context, db *gorm.DB) error {
if db == nil {
return errors.New("数据库未初始化")
}
sqlDB, err := db.DB()
if err != nil {
return err
@@ -77,11 +77,7 @@ func checkDatabase(ctx context.Context) error {
}
// checkRedis 检查Redis连接
func checkRedis(ctx context.Context) error {
client, err := redis.GetClient()
if err != nil {
return err
}
func checkRedis(ctx context.Context, client *redis.Client) error {
if client == nil {
return errors.New("Redis客户端未初始化")
}

View File

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

View File

@@ -201,7 +201,7 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
if emailRegex.MatchString(request.Identifier) {
userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier)
} 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 {
h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ type UserRepository interface {
FindByUsername(ctx context.Context, username string) (*model.User, error)
FindByEmail(ctx context.Context, email string) (*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
UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) 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
GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, 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 {
GetPasswordByID(ctx context.Context, id int64) (string, error)
ResetPassword(ctx context.Context, id int64, password string) error
// Create 创建Yggdrasil密码记录用于首次设置密码
Create(ctx context.Context, yggdrasil *model.Yggdrasil) error
}
// ClientRepository Client仓储接口

View File

@@ -210,3 +210,27 @@ func (r *textureRepository) CountByUploaderID(ctx context.Context, uploaderID in
Count(&count).Error
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
}
// 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 {
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
}
// Create 创建Yggdrasil密码记录
func (r *yggdrasilRepository) Create(ctx context.Context, yggdrasil *model.Yggdrasil) error {
return r.db.WithContext(ctx).Create(yggdrasil).Error
}

View File

@@ -72,13 +72,15 @@ type RedisData struct {
// captchaService CaptchaService的实现
type captchaService struct {
cfg *config.Config
redis *redis.Client
logger *zap.Logger
}
// 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{
cfg: cfg,
redis: redisClient,
logger: logger,
}
@@ -157,8 +159,7 @@ func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, capt
// Verify 验证验证码
func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (bool, error) {
// 测试环境下直接通过验证
cfg, err := config.GetConfig()
if err == nil && cfg.IsTestEnvironment() {
if s.cfg.IsTestEnvironment() {
return true, nil
}
@@ -197,8 +198,7 @@ func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (
// CheckVerified 检查验证码是否已验证仅检查captcha_id
func (s *captchaService) CheckVerified(ctx context.Context, captchaID string) (bool, error) {
// 测试环境下直接通过验证
cfg, err := config.GetConfig()
if err == nil && cfg.IsTestEnvironment() {
if s.cfg.IsTestEnvironment() {
return true, nil
}
@@ -216,8 +216,7 @@ func (s *captchaService) CheckVerified(ctx context.Context, captchaID string) (b
// ConsumeVerified 消耗已验证的验证码(注册成功后调用)
func (s *captchaService) ConsumeVerified(ctx context.Context, captchaID string) error {
// 测试环境下直接返回成功
cfg, err := config.GetConfig()
if err == nil && cfg.IsTestEnvironment() {
if s.cfg.IsTestEnvironment() {
return nil
}

View File

@@ -1,18 +1,11 @@
package service
import (
"errors"
"fmt"
)
// 通用错误
var (
ErrProfileNotFound = errors.New("档案不存在")
ErrProfileNoPermission = errors.New("无权操作此档案")
ErrTextureNotFound = errors.New("材质不存在")
ErrTextureNoPermission = errors.New("无权操作此材质")
ErrUserNotFound = errors.New("用户不存在")
)
// 通用辅助函数。
//
// 注意:错误定义已统一到 internal/errors 包。
// 原先在此文件重复定义的 ErrProfileNotFound/ErrProfileNoPermission/
// ErrTextureNotFound/ErrTextureNoPermission/ErrUserNotFound 已删除,
// 请使用 internal/errors别名 apperrors中的对应定义。
// NormalizePagination 规范化分页参数
func NormalizePagination(page, pageSize int) (int, int) {
@@ -27,11 +20,3 @@ func NormalizePagination(page, pageSize int) (int, int) {
}
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
import (
"errors"
"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
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 档案服务接口
@@ -73,6 +78,11 @@ type TextureService interface {
// 限制检查
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 令牌服务接口

View File

@@ -130,6 +130,24 @@ func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []int64) ([]*mod
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
type MockProfileRepository struct {
profiles map[string]*model.Profile
@@ -474,6 +492,32 @@ func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (i
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

View File

@@ -1,6 +1,7 @@
package service
import (
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
@@ -100,7 +101,7 @@ func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Pro
profile2, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProfileNotFound
return nil, apperrors.ErrProfileNotFound
}
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)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProfileNotFound
return nil, apperrors.ErrProfileNotFound
}
return nil, fmt.Errorf("查询档案失败: %w", err)
}
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)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrProfileNotFound
return apperrors.ErrProfileNotFound
}
return fmt.Errorf("查询档案失败: %w", err)
}
if profile.UserID != userID {
return ErrProfileNoPermission
return apperrors.ErrProfileNoPermission
}
if err := s.profileRepo.Delete(ctx, uuid); err != nil {

View File

@@ -53,7 +53,7 @@ func NewSignatureService(
}
// 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)
if err != nil {
return nil, fmt.Errorf("生成RSA密钥对失败: %w", err)
@@ -85,7 +85,7 @@ func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) {
refresh := now.AddDate(0, 0, RefreshDays)
// 获取Yggdrasil根密钥并签名公钥
yggPublicKey, yggPrivateKey, err := s.GetOrCreateYggdrasilKeyPair()
yggPublicKey, yggPrivateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil {
return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err)
}
@@ -132,9 +132,7 @@ func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) {
}
// GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对
func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKey, error) {
ctx := context.Background()
func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) {
// 尝试从Redis获取密钥
publicKeyPEM, err := s.redis.Get(ctx, PublicKeyRedisKey)
if err == nil && publicKeyPEM != "" {
@@ -201,15 +199,14 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKe
}
// GetPublicKeyFromRedis 从Redis获取公钥
func (s *SignatureService) GetPublicKeyFromRedis() (string, error) {
ctx := context.Background()
func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, error) {
publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey)
if err != nil {
return "", fmt.Errorf("从Redis获取公钥失败: %w", err)
}
if publicKey == "" {
// 如果Redis中没有创建新的密钥对
publicKey, _, err = s.GetOrCreateYggdrasilKeyPair()
publicKey, _, err = s.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil {
return "", fmt.Errorf("创建新密钥对失败: %w", err)
}
@@ -218,14 +215,13 @@ func (s *SignatureService) GetPublicKeyFromRedis() (string, error) {
}
// SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串
func (s *SignatureService) SignStringWithSHA1withRSA(data string) (string, error) {
ctx := context.Background()
func (s *SignatureService) SignStringWithSHA1withRSA(ctx context.Context, data string) (string, error) {
// 从Redis获取私钥
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
if err != nil || privateKeyPEM == "" {
// 如果没有私钥,创建新的密钥对
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair()
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil {
return "", fmt.Errorf("获取私钥失败: %w", err)
}

View File

@@ -2,6 +2,7 @@ package service
import (
"bytes"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
@@ -13,8 +14,10 @@ import (
"fmt"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
)
// textureService TextureService的实现
@@ -26,6 +29,7 @@ type textureService struct {
cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator
logger *zap.Logger
db *gorm.DB
}
// NewTextureService 创建TextureService实例
@@ -35,6 +39,7 @@ func NewTextureService(
storageClient *storage.StorageClient,
cacheManager *database.CacheManager,
logger *zap.Logger,
db *gorm.DB,
) TextureService {
return &textureService{
textureRepo: textureRepo,
@@ -44,6 +49,7 @@ func NewTextureService(
cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager),
logger: logger,
db: db,
}
}
@@ -62,7 +68,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
return nil, err
}
if texture2 == nil {
return nil, ErrTextureNotFound
return nil, apperrors.ErrTextureNotFound
}
if texture2.Status == -1 {
return nil, errors.New("材质已删除")
@@ -80,7 +86,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
return nil, err
}
if texture2 == nil {
return nil, ErrTextureNotFound
return nil, apperrors.ErrTextureNotFound
}
if texture2.Status == -1 {
return nil, errors.New("材质已删除")
@@ -109,7 +115,7 @@ func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Tex
return nil, err
}
if texture2 == nil {
return nil, ErrTextureNotFound
return nil, apperrors.ErrTextureNotFound
}
if texture2.Status == -1 {
return nil, errors.New("材质已删除")
@@ -162,10 +168,10 @@ func (s *textureService) Update(ctx context.Context, textureID, uploaderID int64
return nil, err
}
if texture == nil {
return nil, ErrTextureNotFound
return nil, apperrors.ErrTextureNotFound
}
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
}
if texture == nil {
return ErrTextureNotFound
return apperrors.ErrTextureNotFound
}
if texture.UploaderID != uploaderID {
return ErrTextureNoPermission
return apperrors.ErrTextureNoPermission
}
err = s.textureRepo.Delete(ctx, textureID)
@@ -225,33 +231,41 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i
return false, err
}
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 {
return false, err
}
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
return favorited, nil
}
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)
if err != nil || user == nil {
return nil, ErrUserNotFound
return nil, apperrors.ErrUserNotFound
}
// 验证文件大小和扩展名
@@ -403,3 +417,28 @@ func parseTextureTypeInternal(textureType string) (model.TextureType, error) {
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)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
tests := []struct {
name string
@@ -617,7 +617,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
tests := []struct {
name string
@@ -675,7 +675,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
}
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background()
@@ -714,7 +714,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
_ = textureRepo.Create(context.Background(), texture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background()
@@ -764,7 +764,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
}
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background()
@@ -807,7 +807,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background()

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"time"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
@@ -173,8 +172,9 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
}
if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil {
s.logger.Warn("存储Token到Redis失败", zap.Error(err))
// 不返回错误因为JWT本身已经生成成功
// Redis 存储失败必须返回错误:否则 Validate 拿不到元数据Token 立即失效
s.logger.Error("存储Token到Redis失败", zap.Error(err))
return nil, nil, "", "", fmt.Errorf("存储Token失败: %w", err)
}
return selectedProfileID, availableProfiles, accessToken, clientToken, nil
@@ -473,7 +473,7 @@ func (s *tokenServiceRedis) validateProfileByUserID(ctx context.Context, userID
profile, err := s.profileRepo.FindByUUID(ctx, UUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if repository.IsNotFound(err) {
return false, errors.New("配置文件不存在")
}
return false, fmt.Errorf("验证配置文件失败: %w", err)

View File

@@ -26,6 +26,7 @@ import (
// userService UserService的实现
type userService struct {
cfg *config.Config
userRepo repository.UserRepository
jwtService *auth.JWTService
redis *redis.Client
@@ -38,6 +39,7 @@ type userService struct {
// NewUserService 创建UserService实例
func NewUserService(
cfg *config.Config,
userRepo repository.UserRepository,
jwtService *auth.JWTService,
redisClient *redis.Client,
@@ -48,6 +50,7 @@ func NewUserService(
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
return &userService{
cfg: cfg,
userRepo: userRepo,
jwtService: jwtService,
redis: redisClient,
@@ -81,7 +84,7 @@ func (s *userService) Register(ctx context.Context, username, password, email, a
// 加密密码
hashedPassword, err := auth.HashPassword(password)
if err != nil {
return nil, "", errors.New("密码加密失败")
return nil, "", fmt.Errorf("密码加密失败: %w", err)
}
// 确定头像URL
@@ -112,7 +115,7 @@ func (s *userService) Register(ctx context.Context, username, password, email, a
// 生成JWT Token
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
if err != nil {
return nil, "", errors.New("生成Token失败")
return nil, "", fmt.Errorf("生成Token失败: %w", err)
}
return user, token, nil
@@ -167,15 +170,20 @@ func (s *userService) Login(ctx context.Context, usernameOrEmail, password, ipAd
// 生成JWT Token
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
if err != nil {
return nil, "", errors.New("生成Token失败")
return nil, "", fmt.Errorf("生成Token失败: %w", err)
}
// 更新最后登录时间
// 更新最后登录时间(非关键路径,错误降级为 Warn
now := time.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,
})
}); err != nil {
s.logger.Warn("更新最后登录时间失败",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
}
// 记录成功登录日志
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 {
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("用户不存在")
}
@@ -249,7 +260,7 @@ func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassw
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
return errors.New("密码加密失败")
return fmt.Errorf("密码加密失败: %w", err)
}
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 {
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("用户不存在")
}
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
return errors.New("密码加密失败")
return fmt.Errorf("密码加密失败: %w", err)
}
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缺少主机名")
}
// 从配置获取允许的域名列表
cfg, err := config.GetConfig()
if err != nil {
allowedDomains := []string{"localhost", "127.0.0.1"}
return s.checkDomainAllowed(host, allowedDomains)
// 从注入的配置获取允许的域名列表
allowedDomains := s.cfg.Security.AllowedDomains
if len(allowedDomains) == 0 {
allowedDomains = []string{"localhost", "127.0.0.1"}
}
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) {
@@ -422,29 +435,23 @@ func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData [
}
func (s *userService) GetMaxProfilesPerUser() int {
cfg, err := config.GetConfig()
if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
if s.cfg.Site.MaxProfilesPerUser <= 0 {
return 5
}
return cfg.Site.MaxProfilesPerUser
return s.cfg.Site.MaxProfilesPerUser
}
func (s *userService) GetMaxTexturesPerUser() int {
cfg, err := config.GetConfig()
if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
if s.cfg.Site.MaxTexturesPerUser <= 0 {
return 50
}
return cfg.Site.MaxTexturesPerUser
return s.cfg.Site.MaxTexturesPerUser
}
// 私有辅助方法
func (s *userService) getDefaultAvatar() string {
cfg, err := config.GetConfig()
if err != nil {
return ""
}
return cfg.Site.DefaultAvatar
return s.cfg.Site.DefaultAvatar
}
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)
}
// ==================== 管理员操作 ====================
// 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 (
"carrotskin/internal/model"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"context"
"testing"
@@ -14,11 +15,12 @@ func TestUserServiceImpl_Register(t *testing.T) {
userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop()
cfg := &config.Config{}
// 初始化Service
// 注意redisClient 和 storageClient 传入 nil因为 Register 方法中没有使用它们
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
userService := NewUserService(cfg, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -128,7 +130,8 @@ func TestUserServiceImpl_Login(t *testing.T) {
_ = userRepo.Create(context.Background(), testUser)
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()
@@ -208,7 +211,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
_ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -255,7 +258,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -289,7 +292,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -316,7 +319,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
_ = userRepo.Create(context.Background(), user2)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -338,7 +341,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
logger := zap.NewNop()
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -374,7 +377,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
// 未配置时走默认值
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 {
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
}

View File

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

View File

@@ -9,14 +9,11 @@ import (
"fmt"
"go.uber.org/zap"
"gorm.io/gorm"
)
// yggdrasilAuthService Yggdrasil认证服务实现
// 负责认证和密码管理
type yggdrasilAuthService struct {
db *gorm.DB
userRepo repository.UserRepository
yggdrasilRepo repository.YggdrasilRepository
logger *zap.Logger
@@ -24,13 +21,11 @@ type yggdrasilAuthService struct {
// NewYggdrasilAuthService 创建Yggdrasil认证服务实例内部使用
func NewYggdrasilAuthService(
db *gorm.DB,
userRepo repository.UserRepository,
yggdrasilRepo repository.YggdrasilRepository,
logger *zap.Logger,
) *yggdrasilAuthService {
return &yggdrasilAuthService{
db: db,
userRepo: userRepo,
yggdrasilRepo: yggdrasilRepo,
logger: logger,
@@ -78,7 +73,7 @@ func (s *yggdrasilAuthService) ResetYggdrasilPassword(ctx context.Context, userI
ID: userID,
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 plainPassword, nil

View File

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

View File

@@ -110,7 +110,7 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
// 只有在 unsigned=false 时才签名
var signature string
if !unsigned {
signature, err = s.signatureService.SignStringWithSHA1withRSA(textureData)
signature, err = s.signatureService.SignStringWithSHA1withRSA(ctx, textureData)
if err != nil {
s.logger.Error("签名textures失败",
zap.Error(err),

View File

@@ -10,8 +10,6 @@ import (
"fmt"
"go.uber.org/zap"
"gorm.io/gorm"
)
// yggdrasilServiceComposite 组合服务,保持接口兼容性
@@ -28,20 +26,20 @@ type yggdrasilServiceComposite struct {
// NewYggdrasilServiceComposite 创建组合服务实例
func NewYggdrasilServiceComposite(
db *gorm.DB,
userRepo repository.UserRepository,
profileRepo repository.ProfileRepository,
yggdrasilRepo repository.YggdrasilRepository,
textureRepo repository.TextureRepository,
signatureService *SignatureService,
redisClient *redis.Client,
logger *zap.Logger,
tokenService TokenService, // 新增TokenService接口
) YggdrasilService {
// 创建各个专门的服务
authService := NewYggdrasilAuthService(db, userRepo, yggdrasilRepo, logger)
authService := NewYggdrasilAuthService(userRepo, yggdrasilRepo, logger)
sessionService := NewSessionService(redisClient, logger)
serializationService := NewSerializationService(
repository.NewTextureRepository(db),
textureRepo,
signatureService,
logger,
)

View File

@@ -2,13 +2,8 @@ package types
import "time"
// BaseResponse 基础响应结构
// @Description 通用API响应结构
type BaseResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// 注意:BaseResponse 与 PaginationResponse 已删除(与 model.Response /
// model.PaginationResponse 重复)。统一使用 model 包的响应结构
// PaginationRequest 分页请求
// @Description 分页查询参数
@@ -17,16 +12,6 @@ type PaginationRequest struct {
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 登录请求
// @Description 用户登录请求参数
type LoginRequest struct {

View File

@@ -63,102 +63,15 @@ func TestTextureType_Constants(t *testing.T) {
}
}
// TestPaginationResponse_Structure 测试分页响应结构
func TestPaginationResponse_Structure(t *testing.T) {
resp := PaginationResponse{
List: []string{"a", "b", "c"},
Total: 100,
Page: 1,
PageSize: 20,
TotalPages: 5,
// TestPaginationRequest_Defaults 测试分页请求结构
// 注意:PaginationResponse 已删除(与 model.PaginationResponse 重复)
func TestPaginationRequest_Defaults(t *testing.T) {
req := PaginationRequest{Page: 1, PageSize: 20}
if req.Page != 1 {
t.Errorf("Page = %d, want 1", req.Page)
}
if resp.Total != 100 {
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)
if req.PageSize != 20 {
t.Errorf("PageSize = %d, want 20", req.PageSize)
}
}
@@ -170,36 +83,10 @@ func TestLoginRequest_Validation(t *testing.T) {
password string
wantValid bool
}{
{
name: "有效的登录请求",
username: "testuser",
password: "password123",
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,
},
{name: "有效的登录请求", username: "testuser", password: "password123", wantValid: true},
{name: "用户名为空", username: "", password: "password123", wantValid: false},
{name: "密码为空", username: "testuser", password: "", wantValid: false},
{name: "密码长度小于6", username: "testuser", password: "12345", wantValid: false},
}
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

@@ -1,46 +1,8 @@
package auth
import (
"carrotskin/pkg/config"
"fmt"
"sync"
)
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
}
// 本文件原包含基于 sync.Once 的全局单例Init/GetJWTService/MustGetJWTService
//
// 在 DI 迁移阶段4全局单例已被移除。JWT 服务由 fx.Provide 构造
// (见 internal/app/infra_module.go通过构造函数参数注入到各消费者。
//
// JWT 服务构造逻辑见 jwt.go 的 NewJWTService()。

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

@@ -1,71 +1,8 @@
package config
import (
"fmt"
"sync"
)
var (
// 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
}
// 本文件原包含基于 sync.Once 的全局单例Init/GetConfig/MustGetConfig/GetRustFSConfig
//
// 在 DI 迁移阶段4全局单例已被移除。配置通过 config.Load() 加载,
// 由 fx.Supply 注入到依赖图中。各消费者通过构造函数参数接收 *Config 或子配置。
//
// 配置加载逻辑见 config.go 的 Load()。

View File

@@ -4,67 +4,19 @@ import (
"testing"
)
// TestGetConfig_NotInitialized 测试未初始化时获取配置
func TestGetConfig_NotInitialized(t *testing.T) {
// 重置全局变量(在实际测试中可能需要更复杂的重置逻辑)
// 注意:由于使用了 sync.Once这个测试主要验证错误处理逻辑
// 测试未初始化时的错误消息
_, err := GetConfig()
if err == nil {
t.Error("未初始化时应该返回错误")
// TestLoad_Config 验证 Load() 能成功加载配置(从环境变量/默认值)
// 全局单例访问器GetConfig/MustGetConfig已在 DI 迁移中移除,
// 配置通过 config.Load() 加载并由 fx.Supply 注入。
func TestLoad_Config(t *testing.T) {
cfg, err := Load()
if err != nil {
t.Fatalf("Load() 返回错误: %v", err)
}
expectedError := "配置未初始化,请先调用 config.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
if cfg == nil {
t.Fatal("Load() 返回 nil 配置")
}
// 验证默认值生效
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 异步设置缓存,避免在主请求链路阻塞
func (cm *CacheManager) SetAsync(ctx context.Context, key string, value interface{}, expiration ...time.Duration) {
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,74 +2,25 @@ package database
import (
"carrotskin/internal/model"
"carrotskin/pkg/config"
"fmt"
"sync"
"go.uber.org/zap"
"gorm.io/gorm"
)
var (
// dbInstance 全局数据库实例(使用 *DB 封装)
dbInstance *DB
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
initError error
)
// 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 获取数据库实例(线程安全)
// 返回 *gorm.DB 以保持向后兼容
func GetDB() (*gorm.DB, error) {
if dbInstance == nil {
return nil, fmt.Errorf("数据库未初始化,请先调用 database.Init()")
}
return dbInstance.DB, nil
}
// GetDBWrapper 获取数据库封装实例(包含连接池统计功能)
func GetDBWrapper() (*DB, error) {
if dbInstance == nil {
return nil, fmt.Errorf("数据库未初始化,请先调用 database.Init()")
}
return dbInstance, nil
}
// MustGetDB 获取数据库实例如果未初始化则panic
// 返回 *gorm.DB 以保持向后兼容
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)
}
// 本文件原包含基于 sync.Once 的全局单例Init/GetDB/MustGetDB/GetDBWrapper
//
// 在 DI 迁移阶段4全局单例已被移除。数据库连接由 fx.Provide 构造
// (见 internal/app/infra_module.go 的 provideDatabase通过构造函数参数
// 注入 *database.DB 与 *gorm.DB 到各消费者。
//
// 数据库构造逻辑见 postgres.go 的 New()。
// AutoMigrateWithDB 使用指定的 *gorm.DB 执行表结构迁移(供 DI 使用)。
// 注意表的创建顺序:先创建被引用的表,再创建引用表。
func AutoMigrateWithDB(db *gorm.DB, logger *zap.Logger) error {
logger.Info("开始执行数据库迁移...")
// 迁移所有表 - 注意顺序:先创建被引用的表,再创建引用表
// 使用分批迁移,避免某些表的问题影响其他表
tables := []interface{}{
// 用户相关表(先创建,因为其他表可能引用它)
&model.User{},
@@ -97,7 +48,6 @@ func AutoMigrate(logger *zap.Logger) error {
&model.CasbinRule{},
}
// 批量迁移表
if err := db.AutoMigrate(tables...); err != nil {
logger.Error("数据库迁移失败", zap.Error(err))
return fmt.Errorf("数据库迁移失败: %w", err)
@@ -106,12 +56,3 @@ func AutoMigrate(logger *zap.Logger) error {
logger.Info("数据库迁移完成")
return nil
}
// Close 关闭数据库连接
func Close() error {
if dbInstance == nil {
return nil
}
return dbInstance.Close()
}

View File

@@ -8,34 +8,16 @@ import (
"gorm.io/gorm"
)
// 使用内存 sqlite 验证 AutoMigrate 关键路径,无需真实 Postgres
// 使用内存 sqlite 验证 AutoMigrateWithDB 关键路径,无需真实 Postgres
// 全局单例访问器Init/GetDB/MustGetDB已在 DI 迁移中移除。
func TestAutoMigrate_WithSQLite(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite err: %v", err)
}
// 创建临时的 *DB 包装器用于测试
// 注意:这里不需要真正的连接池功能,只是测试 AutoMigrate
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sql.DB err: %v", err)
}
tempDB := &DB{
DB: db,
sqlDB: sqlDB,
}
// 保存原始实例
originalDB := dbInstance
defer func() { dbInstance = originalDB }()
// 替换为测试实例
dbInstance = tempDB
logger := zaptest.NewLogger(t)
if err := AutoMigrate(logger); err != nil {
t.Fatalf("AutoMigrate sqlite err: %v", err)
if err := AutoMigrateWithDB(db, logger); err != nil {
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

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

View File

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

View File

@@ -1,54 +1,8 @@
package email
import (
"carrotskin/pkg/config"
"fmt"
"sync"
"go.uber.org/zap"
)
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
}
// 本文件原包含基于 sync.Once 的全局单例Init/GetService/MustGetService
//
// 在 DI 迁移阶段4全局单例已被移除。Email 服务由 fx.Provide(email.NewService)
// 构造,通过构造函数参数注入到各消费者。
//
// 邮件服务构造逻辑见 email.go 的 NewService()。

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
import (
"carrotskin/pkg/config"
"fmt"
"sync"
"go.uber.org/zap"
)
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
}
// 本文件原包含基于 sync.Once 的全局单例Init/GetLogger/MustGetLogger
//
// 在 DI 迁移阶段4全局单例已被移除。Logger 由 fx.Provide(logger.New)
// 构造,通过构造函数参数注入到各消费者。
//
// 日志构造逻辑见 logger.go 的 New()。

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

@@ -1,55 +1,8 @@
package storage
import (
"carrotskin/pkg/config"
"fmt"
"sync"
)
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
}
// 本文件原包含基于 sync.Once 的全局单例Init/GetClient/MustGetClient
//
// 在 DI 迁移阶段4全局单例已被移除。Storage 客户端由 fx.Provide(storage.NewStorage)
// 构造,通过构造函数参数注入到各消费者。
//
// 存储客户端构造逻辑见 minio.go 的 NewStorage()。

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)
}
}