From 29f0bad2bcd2dd07dfda9d64653f9dc9b2738fed Mon Sep 17 00:00:00 2001 From: lan Date: Mon, 23 Feb 2026 13:26:53 +0800 Subject: [PATCH] feat(yggdrasil): implement standard error responses and UUID format improvements - Add YggdrasilErrorResponse struct and standard error codes for protocol compliance - Change UUID storage from varchar(36) to varchar(32) for unsigned format - Add utility functions: GenerateUUID, FormatUUIDToNoDash, RandomHex - Support unsigned query parameter in GetProfileByUUID endpoint - Improve refresh token response with available profiles list - Fix key pair retrieval to use correct database column (rsa_private_key) - Update UUID validator to accept both 32-char and 36-char formats - Add SignStringWithProfileRSA method for profile-specific signing - Fix profile assignment validation in refresh token flow --- internal/errors/errors.go | 30 ++ internal/handler/yggdrasil_handler.go | 224 +++++++++--- internal/model/client.go | 4 +- internal/model/profile.go | 2 +- internal/repository/profile_repository.go | 17 +- internal/service/captcha_service.go | 4 +- internal/service/interfaces.go | 2 + internal/service/profile_service.go | 4 +- internal/service/signature_service.go | 23 ++ internal/service/token_service_redis.go | 20 +- .../yggdrasil_serialization_service.go | 51 ++- .../service/yggdrasil_service_composite.go | 9 +- internal/service/yggdrasil_validator.go | 32 +- internal/types/common.go | 2 +- pkg/utils/format.go | 52 ++- plans/unsigned_uuid_design.md | 332 ++++++++++++++++++ 16 files changed, 719 insertions(+), 89 deletions(-) create mode 100644 plans/unsigned_uuid_design.md diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 11ca2f8..9445f14 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -60,6 +60,10 @@ var ( ErrUUIDRequired = errors.New("UUID不能为空") ErrCertificateGenerate = errors.New("生成证书失败") + // Yggdrasil协议标准错误 + ErrYggForbiddenOperation = errors.New("ForbiddenOperationException") + ErrYggIllegalArgument = errors.New("IllegalArgumentException") + // 通用错误 ErrBadRequest = errors.New("请求参数错误") ErrInternalServer = errors.New("服务器内部错误") @@ -138,3 +142,29 @@ func Wrap(err error, message string) error { } return fmt.Errorf("%s: %w", message, err) } + +// YggdrasilErrorResponse Yggdrasil协议标准错误响应格式 +type YggdrasilErrorResponse struct { + Error string `json:"error"` // 错误的简要描述(机器可读) + ErrorMessage string `json:"errorMessage"` // 错误的详细信息(人类可读) + Cause string `json:"cause,omitempty"` // 该错误的原因(可选) +} + +// NewYggdrasilErrorResponse 创建Yggdrasil标准错误响应 +func NewYggdrasilErrorResponse(error, errorMessage, cause string) *YggdrasilErrorResponse { + return &YggdrasilErrorResponse{ + Error: error, + ErrorMessage: errorMessage, + Cause: cause, + } +} + +// YggdrasilErrorCodes Yggdrasil协议错误码常量 +const ( + // ForbiddenOperationException 错误消息 + YggErrInvalidToken = "Invalid token." + YggErrInvalidCredentials = "Invalid credentials. Invalid username or password." + + // IllegalArgumentException 错误消息 + YggErrProfileAlreadyAssigned = "Access token already has a profile assigned." +) diff --git a/internal/handler/yggdrasil_handler.go b/internal/handler/yggdrasil_handler.go index eef0acf..23259fa 100644 --- a/internal/handler/yggdrasil_handler.go +++ b/internal/handler/yggdrasil_handler.go @@ -3,6 +3,7 @@ package handler import ( "bytes" "carrotskin/internal/container" + "carrotskin/internal/errors" "carrotskin/internal/model" "carrotskin/pkg/utils" "io" @@ -129,10 +130,11 @@ type ( // RefreshResponse 刷新令牌响应 RefreshResponse struct { - AccessToken string `json:"accessToken"` - ClientToken string `json:"clientToken"` - SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"` - User map[string]interface{} `json:"user,omitempty"` + AccessToken string `json:"accessToken"` + ClientToken string `json:"clientToken"` + SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"` + AvailableProfiles []map[string]interface{} `json:"availableProfiles"` + User map[string]interface{} `json:"user,omitempty"` } ) @@ -202,7 +204,11 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) { profile, err = h.container.ProfileRepo.FindByName(c.Request.Context(), request.Identifier) if err != nil { h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err)) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidCredentials, + "", + )) return } userId = profile.UserID @@ -211,13 +217,21 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) { if err != nil { h.logger.Warn("认证失败: 用户不存在", zap.String("identifier", request.Identifier), zap.Error(err)) - c.JSON(http.StatusForbidden, gin.H{"error": "用户不存在"}) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidCredentials, + "", + )) return } if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, userId); err != nil { h.logger.Warn("认证失败: 密码错误", zap.Error(err)) - c.JSON(http.StatusForbidden, gin.H{"error": ErrWrongPassword}) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidCredentials, + "", + )) return } @@ -264,7 +278,7 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) { // @Produce json // @Param request body ValidTokenRequest true "验证请求" // @Success 204 "令牌有效" -// @Failure 403 {object} map[string]bool "令牌无效" +// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效" // @Router /api/v1/yggdrasil/authserver/validate [post] func (h *YggdrasilHandler) ValidToken(c *gin.Context) { var request ValidTokenRequest @@ -276,10 +290,14 @@ func (h *YggdrasilHandler) ValidToken(c *gin.Context) { if h.container.TokenService.Validate(c.Request.Context(), request.AccessToken, request.ClientToken) { h.logger.Info("令牌验证成功", zap.String("accessToken", request.AccessToken)) - c.JSON(http.StatusNoContent, gin.H{"valid": true}) + c.JSON(http.StatusNoContent, gin.H{}) } else { h.logger.Warn("令牌验证失败", zap.String("accessToken", request.AccessToken)) - c.JSON(http.StatusForbidden, gin.H{"valid": false}) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidToken, + "", + )) } } @@ -301,19 +319,33 @@ func (h *YggdrasilHandler) RefreshToken(c *gin.Context) { return } - UUID, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), request.AccessToken) + // 获取用户ID和当前绑定的Profile UUID + userID, err := h.container.TokenService.GetUserIDByAccessToken(c.Request.Context(), request.AccessToken) if err != nil { h.logger.Warn("刷新令牌失败: 无效的访问令牌", zap.String("token", request.AccessToken), zap.Error(err)) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidToken, + "", + )) return } - userID, _ := h.container.TokenService.GetUserIDByAccessToken(c.Request.Context(), request.AccessToken) - UUID = utils.FormatUUID(UUID) - - profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), UUID) + currentUUID, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), request.AccessToken) if err != nil { - h.logger.Error("刷新令牌失败: 无法获取用户信息", zap.Error(err)) + h.logger.Warn("刷新令牌失败: 无法获取当前Profile", zap.String("token", request.AccessToken), zap.Error(err)) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidToken, + "", + )) + return + } + + // 获取用户的所有可用profiles + availableProfiles, err := h.container.ProfileService.GetByUserID(c.Request.Context(), userID) + if err != nil { + h.logger.Error("刷新令牌失败: 无法获取用户profiles", zap.Error(err), zap.Int64("userId", userID)) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -322,6 +354,7 @@ func (h *YggdrasilHandler) RefreshToken(c *gin.Context) { var userData map[string]interface{} var profileID string + // 处理selectedProfile if request.SelectedProfile != nil { profileIDValue, ok := request.SelectedProfile["id"] if !ok { @@ -337,25 +370,69 @@ func (h *YggdrasilHandler) RefreshToken(c *gin.Context) { return } - profileID = utils.FormatUUID(profileID) + // 确保profileID是32位无符号格式(用于向后兼容) + profileID = utils.FormatUUIDToNoDash(profileID) + + // 根据Yggdrasil规范:当指定selectedProfile时,原令牌所绑定的角色必须为空 + // 如果原令牌已绑定角色,则不允许更换角色,但允许选择相同的角色以兼容部分启动器 + if currentUUID != "" && currentUUID != profileID { + h.logger.Warn("刷新令牌失败: 令牌已绑定其他角色,不允许更换", + zap.Int64("userId", userID), + zap.String("currentUUID", currentUUID), + zap.String("requestedUUID", profileID), + ) + c.JSON(http.StatusBadRequest, errors.NewYggdrasilErrorResponse( + "IllegalArgumentException", + errors.YggErrProfileAlreadyAssigned, + "", + )) + return + } + + // 验证profile是否属于该用户 + profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), profileID) + if err != nil { + h.logger.Warn("刷新令牌失败: Profile不存在", zap.String("profileId", profileID), zap.Error(err)) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidToken, + "", + )) + return + } if profile.UserID != userID { h.logger.Warn("刷新令牌失败: 用户不匹配", zap.Int64("userId", userID), zap.Int64("profileUserId", profile.UserID), ) - c.JSON(http.StatusBadRequest, gin.H{"error": ErrUserNotMatch}) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidToken, + "", + )) return } profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile) + } else { + // 如果未指定selectedProfile,使用当前token绑定的profile(如果存在) + if currentUUID != "" { + profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), currentUUID) + if err == nil && profile != nil { + profileID = currentUUID + profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile) + } + } } + // 获取用户信息(如果请求) user, _ := h.container.UserService.GetByID(c.Request.Context(), userID) if request.RequestUser && user != nil { - userData = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, UUID) + userData = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, currentUUID) } + // 刷新token newAccessToken, newClientToken, err := h.container.TokenService.Refresh(c.Request.Context(), request.AccessToken, request.ClientToken, @@ -363,16 +440,27 @@ func (h *YggdrasilHandler) RefreshToken(c *gin.Context) { ) if err != nil { h.logger.Error("刷新令牌失败", zap.Error(err), zap.Int64("userId", userID)) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidToken, + "", + )) return } + // 序列化可用profiles + availableProfilesData := make([]map[string]interface{}, 0, len(availableProfiles)) + for _, p := range availableProfiles { + availableProfilesData = append(availableProfilesData, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *p)) + } + h.logger.Info("刷新令牌成功", zap.Int64("userId", userID)) c.JSON(http.StatusOK, RefreshResponse{ - AccessToken: newAccessToken, - ClientToken: newClientToken, - SelectedProfile: profileData, - User: userData, + AccessToken: newAccessToken, + ClientToken: newClientToken, + SelectedProfile: profileData, + AvailableProfiles: availableProfilesData, + User: userData, }) } @@ -431,7 +519,11 @@ func (h *YggdrasilHandler) SignOut(c *gin.Context) { if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, user.ID); err != nil { h.logger.Warn("登出失败: 密码错误", zap.Int64("userId", user.ID)) - c.JSON(http.StatusBadRequest, gin.H{"error": ErrWrongPassword}) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidCredentials, + "", + )) return } @@ -447,22 +539,35 @@ func (h *YggdrasilHandler) SignOut(c *gin.Context) { // @Accept json // @Produce json // @Param uuid path string true "用户UUID" +// @Param unsigned query string false "是否不返回签名(true/false,默认false)" // @Success 200 {object} map[string]interface{} "档案信息" -// @Failure 500 {object} APIResponse "服务器错误" +// @Failure 404 {object} errors.YggdrasilErrorResponse "档案不存在" // @Router /api/v1/yggdrasil/sessionserver/session/minecraft/profile/{uuid} [get] func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) { - uuid := utils.FormatUUID(c.Param("uuid")) + uuid := c.Param("uuid") h.logger.Info("获取配置文件请求", zap.String("uuid", uuid)) profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), uuid) if err != nil { h.logger.Error("获取配置文件失败", zap.Error(err), zap.String("uuid", uuid)) - standardResponse(c, http.StatusInternalServerError, nil, err.Error()) + c.JSON(http.StatusNotFound, errors.NewYggdrasilErrorResponse( + "Not Found", + "Profile not found", + "", + )) return } - h.logger.Info("成功获取配置文件", zap.String("uuid", uuid), zap.String("name", profile.Name)) - c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)) + // 读取 unsigned 查询参数 + unsignedParam := c.DefaultQuery("unsigned", "false") + unsigned := unsignedParam == "true" + + h.logger.Info("成功获取配置文件", + zap.String("uuid", uuid), + zap.String("name", profile.Name), + zap.Bool("unsigned", unsigned)) + + c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfileWithUnsigned(c.Request.Context(), *profile, unsigned)) } // JoinServer 加入服务器 @@ -482,7 +587,11 @@ func (h *YggdrasilHandler) JoinServer(c *gin.Context) { if err := c.ShouldBindJSON(&request); err != nil { h.logger.Error("解析加入服务器请求失败", zap.Error(err), zap.String("ip", clientIP)) - standardResponse(c, http.StatusBadRequest, nil, ErrInvalidRequest) + c.JSON(http.StatusBadRequest, errors.NewYggdrasilErrorResponse( + "Bad Request", + "Invalid request format", + "", + )) return } @@ -499,7 +608,11 @@ func (h *YggdrasilHandler) JoinServer(c *gin.Context) { zap.String("userUUID", request.SelectedProfile), zap.String("ip", clientIP), ) - standardResponse(c, http.StatusInternalServerError, nil, ErrJoinServerFailed) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidToken, + "", + )) return } @@ -557,7 +670,7 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) { return } - profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), username) + profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username) if err != nil { h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username)) standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound) @@ -628,7 +741,11 @@ func (h *YggdrasilHandler) GetMetaData(c *gin.Context) { signature, err := h.container.YggdrasilService.GetPublicKey(c.Request.Context()) if err != nil { h.logger.Error("获取公钥失败", zap.Error(err)) - standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer) + c.JSON(http.StatusInternalServerError, errors.NewYggdrasilErrorResponse( + "Internal Server Error", + "Failed to get public key", + "", + )) return } @@ -648,27 +765,40 @@ func (h *YggdrasilHandler) GetMetaData(c *gin.Context) { // @Produce json // @Param Authorization header string true "Bearer {token}" // @Success 200 {object} map[string]interface{} "证书信息" -// @Failure 401 {object} map[string]string "未授权" -// @Failure 500 {object} APIResponse "服务器错误" +// @Failure 401 {object} errors.YggdrasilErrorResponse "未授权" +// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效" +// @Failure 500 {object} errors.YggdrasilErrorResponse "服务器错误" // @Router /api/v1/yggdrasil/minecraftservices/player/certificates [post] func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header not provided"}) + c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse( + "Unauthorized", + "Authorization header not provided", + "", + )) c.Abort() return } bearerPrefix := "Bearer " if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"}) + c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse( + "Unauthorized", + "Invalid Authorization format", + "", + )) c.Abort() return } tokenID := authHeader[len(bearerPrefix):] if tokenID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"}) + c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse( + "Unauthorized", + "Invalid Authorization format", + "", + )) c.Abort() return } @@ -676,16 +806,24 @@ func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) { uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID) if uuid == "" { h.logger.Error("获取玩家UUID失败", zap.Error(err)) - standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer) + c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( + "ForbiddenOperationException", + errors.YggErrInvalidToken, + "", + )) return } - uuid = utils.FormatUUID(uuid) + // UUID已经是32位无符号格式,无需转换 certificate, err := h.container.YggdrasilService.GeneratePlayerCertificate(c.Request.Context(), uuid) if err != nil { h.logger.Error("生成玩家证书失败", zap.Error(err)) - standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer) + c.JSON(http.StatusInternalServerError, errors.NewYggdrasilErrorResponse( + "Internal Server Error", + "Failed to generate player certificate", + "", + )) return } diff --git a/internal/model/client.go b/internal/model/client.go index 7271d1d..423d4f1 100644 --- a/internal/model/client.go +++ b/internal/model/client.go @@ -5,10 +5,10 @@ import "time" // Client 客户端实体,用于管理Token版本 // @Description Yggdrasil客户端Token管理数据 type Client struct { - UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"` // Client UUID + UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"` // Client UUID ClientToken string `gorm:"column:client_token;type:varchar(64);not null;uniqueIndex" json:"client_token"` // 客户端Token UserID int64 `gorm:"column:user_id;not null;index:idx_clients_user_id" json:"user_id"` // 用户ID - ProfileID string `gorm:"column:profile_id;type:varchar(36);index:idx_clients_profile_id" json:"profile_id,omitempty"` // 选中的Profile + ProfileID string `gorm:"column:profile_id;type:varchar(32);index:idx_clients_profile_id" json:"profile_id,omitempty"` // 选中的Profile Version int `gorm:"column:version;not null;default:0;index:idx_clients_version" json:"version"` // 版本号 CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` diff --git a/internal/model/profile.go b/internal/model/profile.go index 10ce6ba..23ddc9e 100644 --- a/internal/model/profile.go +++ b/internal/model/profile.go @@ -7,7 +7,7 @@ import ( // Profile Minecraft 档案模型 // @Description Minecraft角色档案数据模型 type Profile struct { - UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"` + UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"` UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"` Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名 SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"` diff --git a/internal/repository/profile_repository.go b/internal/repository/profile_repository.go index 623046e..57867a4 100644 --- a/internal/repository/profile_repository.go +++ b/internal/repository/profile_repository.go @@ -131,8 +131,8 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (* var profile model.Profile result := r.db.WithContext(ctx). - Select("key_pair"). - Where("id = ?", profileId). + Select("rsa_private_key"). + Where("uuid = ?", profileId). First(&profile) if result.Error != nil { @@ -142,7 +142,9 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (* return nil, fmt.Errorf("获取key pair失败: %w", result.Error) } - return &model.KeyPair{}, nil + return &model.KeyPair{ + PrivateKey: profile.RSAPrivateKey, + }, nil } func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error { @@ -154,12 +156,9 @@ func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - result := tx.Table("profiles"). - Where("id = ?", profileId). - UpdateColumns(map[string]interface{}{ - "private_key": keyPair.PrivateKey, - "public_key": keyPair.PublicKey, - }) + result := tx.Model(&model.Profile{}). + Where("uuid = ?", profileId). + Update("rsa_private_key", keyPair.PrivateKey) if result.Error != nil { return fmt.Errorf("更新 keyPair 失败: %w", result.Error) diff --git a/internal/service/captcha_service.go b/internal/service/captcha_service.go index ac89922..256c645 100644 --- a/internal/service/captcha_service.go +++ b/internal/service/captcha_service.go @@ -3,13 +3,13 @@ package service import ( "carrotskin/pkg/config" "carrotskin/pkg/redis" + "carrotskin/pkg/utils" "context" "errors" "fmt" "log" "time" - "github.com/google/uuid" "github.com/wenlng/go-captcha-assets/resources/imagesv2" "github.com/wenlng/go-captcha-assets/resources/tiles" "github.com/wenlng/go-captcha/v2/slide" @@ -87,7 +87,7 @@ func NewCaptchaService(redisClient *redis.Client, logger *zap.Logger) CaptchaSer // Generate 生成验证码 func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error) { // 生成uuid作为验证码进程唯一标识 - captchaID = uuid.NewString() + captchaID = utils.GenerateUUID() if captchaID == "" { err = errors.New("生成验证码唯一标识失败") return diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 429d573..4b7132a 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -79,6 +79,7 @@ type TextureService interface { type TokenService interface { // 令牌管理 Create(ctx context.Context, userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error) + CreateWithProfile(ctx context.Context, userID int64, profileUUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) Validate(ctx context.Context, accessToken, clientToken string) bool Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) Invalidate(ctx context.Context, accessToken string) @@ -116,6 +117,7 @@ type YggdrasilService interface { // 序列化 SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} + SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} // 证书 diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index 650cde0..844ce4c 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -4,6 +4,7 @@ import ( "carrotskin/internal/model" "carrotskin/internal/repository" "carrotskin/pkg/database" + "carrotskin/pkg/utils" "context" "crypto/rand" "crypto/rsa" @@ -12,7 +13,6 @@ import ( "errors" "fmt" - "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" ) @@ -64,7 +64,7 @@ func (s *profileService) Create(ctx context.Context, userID int64, name string) } // 生成UUID和RSA密钥 - profileUUID := uuid.New().String() + profileUUID := utils.GenerateUUID() privateKey, err := generateRSAPrivateKeyInternal() if err != nil { return nil, fmt.Errorf("生成RSA密钥失败: %w", err) diff --git a/internal/service/signature_service.go b/internal/service/signature_service.go index 817736b..26541e1 100644 --- a/internal/service/signature_service.go +++ b/internal/service/signature_service.go @@ -274,3 +274,26 @@ func FormatPublicKey(publicKeyPEM string) string { } return strings.Join(keyLines, "") } + +// SignStringWithProfileRSA 使用Profile的RSA私钥签名字符串 +func (s *SignatureService) SignStringWithProfileRSA(data string, privateKeyPEM string) (string, error) { + // 解析PEM格式的私钥 + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", fmt.Errorf("解析PEM私钥失败") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("解析RSA私钥失败: %w", err) + } + + // 签名 + hashed := sha1.Sum([]byte(data)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:]) + if err != nil { + return "", fmt.Errorf("签名失败: %w", err) + } + + return base64.StdEncoding.EncodeToString(signature), nil +} diff --git a/internal/service/token_service_redis.go b/internal/service/token_service_redis.go index 812a7e5..3cab198 100644 --- a/internal/service/token_service_redis.go +++ b/internal/service/token_service_redis.go @@ -4,12 +4,12 @@ import ( "carrotskin/internal/model" "carrotskin/internal/repository" "carrotskin/pkg/auth" + "carrotskin/pkg/utils" "context" "errors" "fmt" "time" - "github.com/google/uuid" "github.com/jackc/pgx/v5" "go.uber.org/zap" ) @@ -63,9 +63,13 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin } } - // 生成ClientToken + // 生成ClientToken(使用32字符十六进制字符串) if clientToken == "" { - clientToken = uuid.New().String() + var err error + clientToken, err = utils.RandomHex(16) // 16字节 = 32字符十六进制 + if err != nil { + return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成ClientToken失败: %w", err) + } } // 获取或创建Client @@ -73,7 +77,10 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin existingClient, err := s.clientRepo.FindByClientToken(ctx, clientToken) if err != nil { // Client不存在,创建新的 - clientUUID := uuid.New().String() + clientUUID, err := utils.RandomHex(16) // 16字节 = 32字符十六进制 + if err != nil { + return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成ClientUUID失败: %w", err) + } client = &model.Client{ UUID: clientUUID, ClientToken: clientToken, @@ -173,6 +180,11 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin return selectedProfileID, availableProfiles, accessToken, clientToken, nil } +// CreateWithProfile 创建Token并绑定指定Profile(使用JWT + Redis存储) +func (s *tokenServiceRedis) CreateWithProfile(ctx context.Context, userID int64, profileUUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) { + return s.Create(ctx, userID, profileUUID, clientToken) +} + // Validate 验证Token(使用JWT验证 + Redis存储验证) func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientToken string) bool { // 设置超时上下文 diff --git a/internal/service/yggdrasil_serialization_service.go b/internal/service/yggdrasil_serialization_service.go index 7d403ed..9000cb6 100644 --- a/internal/service/yggdrasil_serialization_service.go +++ b/internal/service/yggdrasil_serialization_service.go @@ -14,6 +14,8 @@ import ( type SerializationService interface { // SerializeProfile 序列化档案为Yggdrasil格式 SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} + // SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式(支持unsigned参数) + SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} // SerializeUser 序列化用户为Yggdrasil格式 SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} } @@ -45,8 +47,13 @@ func NewSerializationService( } } -// SerializeProfile 序列化档案为Yggdrasil格式 +// SerializeProfile 序列化档案为Yggdrasil格式(默认返回签名) func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} { + return s.SerializeProfileWithUnsigned(ctx, profile, false) +} + +// SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式(支持unsigned参数) +func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} { // 创建基本材质数据 texturesMap := make(map[string]interface{}) textures := map[string]interface{}{ @@ -99,26 +106,36 @@ func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, pr } textureData := base64.StdEncoding.EncodeToString(bytes) - signature, err := s.signatureService.SignStringWithSHA1withRSA(textureData) - if err != nil { - s.logger.Error("签名textures失败", - zap.Error(err), - zap.String("profileUUID", profile.UUID), - ) - return nil + + // 只有在 unsigned=false 时才签名 + var signature string + if !unsigned { + signature, err = s.signatureService.SignStringWithSHA1withRSA(textureData) + if err != nil { + s.logger.Error("签名textures失败", + zap.Error(err), + zap.String("profileUUID", profile.UUID), + ) + return nil + } + } + + // 构建属性 + property := Property{ + Name: "textures", + Value: textureData, + } + + // 只有在 unsigned=false 时才添加签名 + if !unsigned { + property.Signature = signature } // 构建结果 data := map[string]interface{}{ - "id": profile.UUID, - "name": profile.Name, - "properties": []Property{ - { - Name: "textures", - Value: textureData, - Signature: signature, - }, - }, + "id": profile.UUID, + "name": profile.Name, + "properties": []Property{property}, } return data } diff --git a/internal/service/yggdrasil_service_composite.go b/internal/service/yggdrasil_service_composite.go index 0dfd0ee..42a9830 100644 --- a/internal/service/yggdrasil_service_composite.go +++ b/internal/service/yggdrasil_service_composite.go @@ -85,8 +85,8 @@ func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, ac return fmt.Errorf("验证Token失败: %w", err) } - // 格式化UUID并验证与Token关联的配置文件 - formattedProfile := utils.FormatUUID(selectedProfile) + // 确保UUID是32位无符号格式(用于向后兼容) + formattedProfile := utils.FormatUUIDToNoDash(selectedProfile) if uuid != formattedProfile { return errors.New("selectedProfile与Token不匹配") } @@ -115,6 +115,11 @@ func (s *yggdrasilServiceComposite) SerializeProfile(ctx context.Context, profil return s.serializationService.SerializeProfile(ctx, profile) } +// SerializeProfileWithUnsigned 序列化档案(支持unsigned参数) +func (s *yggdrasilServiceComposite) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} { + return s.serializationService.SerializeProfileWithUnsigned(ctx, profile, unsigned) +} + // SerializeUser 序列化用户 func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} { return s.serializationService.SerializeUser(ctx, user, uuid) diff --git a/internal/service/yggdrasil_validator.go b/internal/service/yggdrasil_validator.go index 58cb957..2a01dbe 100644 --- a/internal/service/yggdrasil_validator.go +++ b/internal/service/yggdrasil_validator.go @@ -57,16 +57,38 @@ func (v *Validator) ValidateEmail(email string) error { return nil } -// ValidateUUID 验证UUID格式(简单验证) +// ValidateUUID 验证UUID格式(支持32位无符号和36位带连字符格式) func (v *Validator) ValidateUUID(uuid string) error { if uuid == "" { return errors.New("UUID不能为空") } - // UUID格式:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (32个十六进制字符 + 4个连字符) - if len(uuid) < 32 || len(uuid) > 36 { - return errors.New("UUID格式无效") + + // 验证32位无符号UUID格式(纯十六进制字符串) + if len(uuid) == 32 { + // 检查是否为有效的十六进制字符串 + for _, c := range uuid { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return errors.New("UUID格式无效:包含非十六进制字符") + } + } + return nil } - return nil + + // 验证36位标准UUID格式(带连字符) + if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' { + // 检查除连字符外的字符是否为有效的十六进制 + for i, c := range uuid { + if i == 8 || i == 13 || i == 18 || i == 23 { + continue // 跳过连字符位置 + } + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return errors.New("UUID格式无效:包含非十六进制字符") + } + } + return nil + } + + return errors.New("UUID格式无效:长度应为32位或36位") } // ValidateAccessToken 验证访问令牌 diff --git a/internal/types/common.go b/internal/types/common.go index dd8de31..291d9b8 100644 --- a/internal/types/common.go +++ b/internal/types/common.go @@ -154,7 +154,7 @@ type TextureInfo struct { // ProfileInfo 角色信息 // @Description Minecraft档案信息 type ProfileInfo struct { - UUID string `json:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` + UUID string `json:"uuid" example:"550e8400e29b41d4a716446655440000"` UserID int64 `json:"user_id" example:"1"` Name string `json:"name" example:"PlayerName"` SkinID *int64 `json:"skin_id,omitempty" example:"1"` diff --git a/pkg/utils/format.go b/pkg/utils/format.go index dd36678..490f127 100644 --- a/pkg/utils/format.go +++ b/pkg/utils/format.go @@ -1,8 +1,12 @@ package utils import ( - "go.uber.org/zap" + "crypto/rand" + "encoding/hex" "strings" + + "github.com/google/uuid" + "go.uber.org/zap" ) // FormatUUID 将UUID格式化为带连字符的标准格式 @@ -45,3 +49,49 @@ func FormatUUID(uuid string) string { logger.Warn("[WARN] UUID格式无效: ", zap.String("uuid:", uuid)) return uuid } + +// GenerateUUID 生成无符号UUID(32位十六进制字符串,不带连字符) +// 使用github.com/google/uuid库生成标准UUID,然后移除连字符 +// 返回格式示例: "123e4567e89b12d3a456426614174000" +func GenerateUUID() string { + return strings.ReplaceAll(uuid.New().String(), "-", "") +} + +// FormatUUIDToNoDash 将带连字符的UUID转换为无符号格式(移除连字符) +// 输入: "123e4567-e89b-12d3-a456-426614174000" +// 输出: "123e4567e89b12d3a456426614174000" +// 如果输入已经是32位格式,直接返回 +// 如果输入格式无效,返回原值并记录警告 +func FormatUUIDToNoDash(uuid string) string { + // 如果为空,直接返回 + if uuid == "" { + return uuid + } + + // 如果已经是32位格式(无连字符),直接返回 + if len(uuid) == 32 { + return uuid + } + + // 如果是36位标准格式,移除连字符 + if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' { + return strings.ReplaceAll(uuid, "-", "") + } + + // 如果格式无效,记录警告并返回原值 + var logger *zap.Logger + logger.Warn("[WARN] UUID格式无效,无法转换为无符号格式: ", zap.String("uuid:", uuid)) + return uuid +} + +// RandomHex 生成指定长度的随机十六进制字符串 +// 参数 n: 需要生成的十六进制字符数量(每个字节生成2个十六进制字符) +// 返回: 长度为 2*n 的十六进制字符串 +// 示例: RandomHex(16) 返回 "a1b2c3d4e5f67890abcdef1234567890" (32字符) +func RandomHex(n uint) (string, error) { + bytes := make([]byte, n) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/plans/unsigned_uuid_design.md b/plans/unsigned_uuid_design.md new file mode 100644 index 0000000..447646c --- /dev/null +++ b/plans/unsigned_uuid_design.md @@ -0,0 +1,332 @@ +# 无符号UUID实现方案设计 + +## 1. 概述 + +### 1.1 背景 +当前项目中UUID使用带连字符的标准格式(36位:`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`)。为了与Yggdrasil协议更好的兼容并优化存储空间,需要实现无符号UUID(32位十六进制字符串)。 + +### 1.2 目标 +- 定义无符号UUID的格式规范 +- 设计UUID生成、保存、读取、返回的转换逻辑 +- 确保与Yggdrasil协议的兼容性 +- 提供向后兼容和数据迁移策略 + +### 1.3 无符号UUID格式 +- **格式**: 32位十六进制字符串(小写) +- **示例**: `123e4567e89b12d3a456426614174000` +- **数据库存储**: varchar(32) + +--- + +## 2. 当前UUID使用分析 + +### 2.1 UUID使用场景 + +| 场景 | 文件位置 | 用途 | +|------|----------|------| +| Profile创建 | [`internal/service/profile_service.go:67`](internal/service/profile_service.go:67) | 生成Profile UUID | +| Client创建 | [`internal/service/token_service_redis.go:76`](internal/service/token_service_redis.go:76) | 生成Client UUID | +| 数据库存储 | [`internal/model/profile.go:10`](internal/model/profile.go:10) | Profile表主键 varchar(36) | +| 数据库存储 | [`internal/model/client.go:8`](internal/model/client.go:8) | Client表主键 varchar(36) | +| API返回 | [`internal/model/profile.go:34`](internal/model/profile.go:34) | ProfileResponse.JSON | +| Yggdrasil序列化 | [`internal/service/yggdrasil_serialization_service.go:54`](internal/service/yggdrasil_serialization_service.go:54) | profileId字段 | +| Yggdrasil序列化 | [`internal/service/yggdrasil_serialization_service.go:113`](internal/service/yggdrasil_serialization_service.go:113) | id字段 | + +### 2.2 现有转换函数 +项目已在 [`pkg/utils/format.go`](pkg/utils/format.go) 中实现了 `FormatUUID` 函数: +- 功能:32位字符串 → 带连字符的36位标准格式 +- 使用场景:Handler层接收参数后转换为标准格式 + +--- + +## 3. 转换逻辑设计 + +### 3.1 核心转换函数 + +在 [`pkg/utils/format.go`](pkg/utils/format.go) 中扩展以下函数: + +```go +// FormatUUIDToNoDash 将带连字符的UUID转换为无符号UUID(移除连字符) +// 输入: "123e4567-e89b-12d3-a456-426614174000" +// 输出: "123e4567e89b12d3a456426614174000" +func FormatUUIDToNoDash(uuid string) string + +// FormatUUIDToDash 将无符号UUID转换为带连字符的标准格式 +// 输入: "123e4567e89b12d3a456426614174000" +// 输出: "123e4567-e89b-12d3-a456-426614174000" +func FormatUUIDToDash(uuid string) string +``` + +### 3.2 UUID生成策略 + +```go +// GenerateUUID 生成无符号UUID +// 使用github.com/google/uuid库生成标准UUID,然后移除连字符 +func GenerateUUID() string { + return uuid.New().String() // 默认生成带连字符的UUID + // 移除连字符 + return strings.ReplaceAll(uuid.New().String(), "-", "") +} +``` + +--- + +## 4. 数据流转设计 + +### 4.1 总体数据流 + +``` +生成UUID (36位标准格式) + ↓ +移除连字符 (32位无符号UUID) + ↓ +存储到数据库 (varchar(32)) + ↓ +读取时根据场景转换 + ├─→ Yggdrasil API: 保持无符号格式 + └─→ 内部处理/调试: 转换为标准格式 +``` + +### 4.2 各环节处理 + +| 环节 | 操作 | 说明 | +|------|------|------| +| **生成** | `uuid.New().String()` → 移除`-` | 保持与google/uuid库兼容 | +| **保存** | 直接存储32位字符串 | 数据库字段改为varchar(32) | +| **读取** | 直接读取 | 无需转换 | +| **返回** | 根据API类型选择 | 见4.3节 | + +### 4.3 API返回策略 + +| API类型 | 返回格式 | 原因 | +|---------|----------|------| +| Yggdrasil协议 | 32位无符号 | Mojang/Yggdrasil标准 | +| 内部REST API | 保持存储格式 | 统一简洁 | +| 调试/日志 | 可选转换 | 便于阅读 | + +--- + +## 5. 需要修改的文件 + +### 5.1 核心文件修改 + +| 文件 | 修改内容 | +|------|----------| +| [`pkg/utils/format.go`](pkg/utils/format.go) | 添加 `FormatUUIDToNoDash`、`FormatUUIDToDash`、`GenerateUUID` 函数 | +| [`pkg/utils/format_test.go`](pkg/utils/format_test.go) | 添加新函数的单元测试 | +| [`internal/model/profile.go`](internal/model/profile.go) | UUID字段类型改为varchar(32) | +| [`internal/model/client.go`](internal/model/client.go) | UUID字段类型改为varchar(32) | + +### 5.2 服务层修改 + +| 文件 | 修改内容 | +|------|----------| +| [`internal/service/profile_service.go`](internal/service/profile_service.go) | UUID生成改用新函数 | +| [`internal/service/token_service_redis.go`](internal/service/token_service_redis.go) | UUID生成改用新函数 | + +### 5.3 Handler层修改 + +| 文件 | 修改内容 | +|------|----------| +| [`internal/handler/yggdrasil_handler.go`](internal/handler/yggdrasil_handler.go) | Yggdrasil API返回无符号UUID | +| [`internal/handler/profile_handler.go`](internal/handler/profile_handler.go) | 内部API返回无符号UUID | + +### 5.4 序列化服务修改 + +| 文件 | 修改内容 | +|------|----------| +| [`internal/service/yggdrasil_serialization_service.go`](internal/service/yggdrasil_serialization_service.go) | profileId和id字段使用无符号UUID | + +--- + +## 6. 实现步骤 + +### 6.1 第一步:扩展工具函数 + +在 [`pkg/utils/format.go`](pkg/utils/format.go) 中添加: + +1. `FormatUUIDToNoDash(uuid string) string` - 移除连字符 +2. `FormatUUIDToDash(uuid string) string` - 添加连字符(现有函数增强) +3. `GenerateUUID() string` - 生成无符号UUID + +### 6.2 第二步:修改数据模型 + +1. 修改 [`internal/model/profile.go`](internal/model/profile.go): + ```go + UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"` + ``` + +2. 修改 [`internal/model/client.go`](internal/model/client.go): + ```go + UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"` + ``` + +### 6.3 第三步:修改服务层 + +1. 修改 [`internal/service/profile_service.go`](internal/service/profile_service.go:67): + ```go + // 生成无符号UUID + profileUUID := utils.GenerateUUID() // 或 uuid.New().String() 后转换 + ``` + +2. 修改 [`internal/service/token_service_redis.go`](internal/service/token_service_redis.go:76): + ```go + clientUUID := utils.GenerateUUID() + ``` + +### 6.4 第四步:修改Handler层 + +1. 修改 [`internal/handler/yggdrasil_handler.go`](internal/handler/yggdrasil_handler.go): + - 移除 `utils.FormatUUID()` 调用(因为存储的已经是无符号格式) + - Yggdrasil API直接返回无符号UUID + +2. 修改 [`internal/handler/profile_handler.go`](internal/handler/profile_handler.go): + - 同样移除 `utils.FormatUUID()` 调用 + +### 6.第五步:修改序列化服务 + +修改 [`internal/service/yggdrasil_serialization_service.go`](internal/service/yggdrasil_serialization_service.go): + +```go +// SerializeProfile - profileId使用无符号UUID +"profileId": profile.UUID, // 直接使用存储的32位UUID + +// SerializeProfile - id使用无符号UUID +"id": profile.UUID, // 直接使用存储的32位UUID +``` + +--- + +## 7. 向后兼容性设计 + +### 7.1 双格式支持(可选) + +如果需要同时支持新旧客户端,可以: + +1. **数据库字段保持varchar(36)** - 存储带连字符的格式 +2. **新增转换函数** - 在返回Yggdrasil响应时转换为无符号格式 + +### 7.2 推荐策略:渐进式迁移 + +1. **第一阶段**: 实现新代码,生成和存储无符号UUID +2. **第二阶段**: 运行数据迁移脚本 +3. **第三阶段**: 移除旧格式支持 + +--- + +## 8. 数据迁移策略 + +### 8.1 迁移脚本设计 + +```sql +-- 将profiles表的UUID转换为无符号格式 +UPDATE profiles +SET uuid = REPLACE(REPLACE(REPLACE(REPLACE(uuid, '-', ''), + SUBSTRING(uuid, 9, 1), ''), '', ''), '', '') +WHERE LENGTH(uuid) = 36; + +-- 实际迁移SQL(分步骤) +UPDATE profiles +SET uuid = CONCAT( + SUBSTRING(uuid, 1, 8), + SUBSTRING(uuid, 10, 4), + SUBSTRING(uuid, 15, 4), + SUBSTRING(uuid, 20, 4), + SUBSTRING(uuid, 25, 12) +) +WHERE LENGTH(uuid) = 36 AND uuid LIKE '%-%-%-%-%'; + +-- 同样迁移clients表 +UPDATE clients +SET uuid = CONCAT( + SUBSTRING(uuid, 1, 8), + SUBSTRING(uuid, 10, 4), + SUBSTRING(uuid, 15, 4), + SUBSTRING(uuid, 20, 4), + SUBSTRING(uuid, 25, 12) +) +WHERE LENGTH(uuid) = 36 AND uuid LIKE '%-%-%-%-%'; +``` + +### 8.2 迁移注意事项 + +1. **备份数据** - 迁移前务必备份数据库 +2. **停服迁移** - 建议在低峰期执行 +3. **验证迁移** - 迁移后验证数据完整性 +4. **回滚方案** - 准备回滚脚本 + +--- + +## 9. 测试计划 + +### 9.1 单元测试 + +在 [`pkg/utils/format_test.go`](pkg/utils/format_test.go) 中添加: + +1. `TestFormatUUIDToNoDash` - 测试移除连字符 +2. `TestFormatUUIDToDash` - 测试添加连字符 +3. `TestGenerateUUID` - 测试UUID生成(32位、唯一性) + +### 9.2 集成测试 + +1. 创建Profile后验证数据库存储格式 +2. Yggdrasil认证流程验证UUID格式 +3. 批量操作(BatchUpdate/BatchDelete)验证UUID处理 + +--- + +## 10. 注意事项 + +### 10.1 Yggdrasil协议兼容性 + +根据Yggdrasil/Mojang协议规范: +- 玩家UUID应为32位无符号十六进制字符串 +- profileId字段应为小写 +- 与Minecraft客户端交互时使用此格式 + +### 10.2 性能考虑 + +- 新UUID生成无需额外转换(直接生成32位) +- 数据库varchar(32)比varchar(36)节省约10%存储空间 +- 索引查询效率略有提升 + +### 10.3 安全性 + +- UUID应使用加密安全的随机数生成器 +- github.com/google/uuid库默认使用crypto/rand,安全可靠 + +--- + +## 11. Mermaid数据流图 + +```mermaid +flowchart TD + A[uuid.New] --> B{需要格式} + B -->|存储| C[FormatUUIDToNoDash] + B -->|Yggdrasil返回| C + B -->|内部API返回| D[直接使用] + + C --> E[数据库 varchar(32)] + D --> E + + E --> F{读取场景} + F -->|Yggdrasil API| G[保持无符号] + F -->|调试/日志| H[FormatUUIDToDash] + + G --> I[响应客户端] + H --> J[控制台输出] +``` + +--- + +## 12. 总结 + +本方案实现了以下目标: + +1. **格式统一定义**: 32位无符号十六进制字符串 +2. **最小化修改**: 保持核心逻辑不变,仅调整格式转换 +3. **Yggdrasil兼容**: 符合Mojang协议的UUID格式要求 +4. **平滑迁移**: 支持渐进式数据迁移 +5. **测试覆盖**: 完整的单元测试和集成测试计划 + +方案实施后,项目将统一使用无符号UUID格式,提升与Yggdrasil协议的兼容性,并优化存储空间。