refactor: 移除全局单例、修复分层违规、统一错误与响应
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
|
||||
// 流式传输文件内容
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -49,14 +50,13 @@ func HealthCheck(c *gin.Context) {
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// checkDatabase 检查数据库连接
|
||||
func checkDatabase(ctx context.Context) error {
|
||||
db, err := database.GetDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// checkDatabase 检查数据库连接
|
||||
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客户端未初始化")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"})
|
||||
})
|
||||
|
||||
@@ -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仓储接口
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 令牌服务接口
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 路径。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()。
|
||||
|
||||
@@ -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应该只执行一次,返回同一个实例")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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...)
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("开始初始化种子数据...")
|
||||
|
||||
// 初始化默认管理员用户
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user