feat(yggdrasil): implement standard error responses and UUID format improvements
All checks were successful
Build / build (push) Successful in 2m17s
Build / build-docker (push) Successful in 57s

- Add YggdrasilErrorResponse struct and standard error codes for protocol compliance
- Change UUID storage from varchar(36) to varchar(32) for unsigned format
- Add utility functions: GenerateUUID, FormatUUIDToNoDash, RandomHex
- Support unsigned query parameter in GetProfileByUUID endpoint
- Improve refresh token response with available profiles list
- Fix key pair retrieval to use correct database column (rsa_private_key)
- Update UUID validator to accept both 32-char and 36-char formats
- Add SignStringWithProfileRSA method for profile-specific signing
- Fix profile assignment validation in refresh token flow
This commit is contained in:
2026-02-23 13:26:53 +08:00
parent 3e8b7d150d
commit 29f0bad2bc
16 changed files with 719 additions and 89 deletions

View File

@@ -60,6 +60,10 @@ var (
ErrUUIDRequired = errors.New("UUID不能为空") ErrUUIDRequired = errors.New("UUID不能为空")
ErrCertificateGenerate = errors.New("生成证书失败") ErrCertificateGenerate = errors.New("生成证书失败")
// Yggdrasil协议标准错误
ErrYggForbiddenOperation = errors.New("ForbiddenOperationException")
ErrYggIllegalArgument = errors.New("IllegalArgumentException")
// 通用错误 // 通用错误
ErrBadRequest = errors.New("请求参数错误") ErrBadRequest = errors.New("请求参数错误")
ErrInternalServer = errors.New("服务器内部错误") ErrInternalServer = errors.New("服务器内部错误")
@@ -138,3 +142,29 @@ func Wrap(err error, message string) error {
} }
return fmt.Errorf("%s: %w", message, err) 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."
)

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import (
// Profile Minecraft 档案模型 // Profile Minecraft 档案模型
// @Description Minecraft角色档案数据模型 // @Description Minecraft角色档案数据模型
type Profile struct { type Profile struct {
UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"` UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"`
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"` UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"`
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名 Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名
SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"` SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"`

View File

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

View File

@@ -3,13 +3,13 @@ package service
import ( import (
"carrotskin/pkg/config" "carrotskin/pkg/config"
"carrotskin/pkg/redis" "carrotskin/pkg/redis"
"carrotskin/pkg/utils"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log" "log"
"time" "time"
"github.com/google/uuid"
"github.com/wenlng/go-captcha-assets/resources/imagesv2" "github.com/wenlng/go-captcha-assets/resources/imagesv2"
"github.com/wenlng/go-captcha-assets/resources/tiles" "github.com/wenlng/go-captcha-assets/resources/tiles"
"github.com/wenlng/go-captcha/v2/slide" "github.com/wenlng/go-captcha/v2/slide"
@@ -87,7 +87,7 @@ func NewCaptchaService(redisClient *redis.Client, logger *zap.Logger) CaptchaSer
// Generate 生成验证码 // Generate 生成验证码
func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error) { func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error) {
// 生成uuid作为验证码进程唯一标识 // 生成uuid作为验证码进程唯一标识
captchaID = uuid.NewString() captchaID = utils.GenerateUUID()
if captchaID == "" { if captchaID == "" {
err = errors.New("生成验证码唯一标识失败") err = errors.New("生成验证码唯一标识失败")
return return

View File

@@ -79,6 +79,7 @@ type TextureService interface {
type TokenService interface { type TokenService interface {
// 令牌管理 // 令牌管理
Create(ctx context.Context, userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error) Create(ctx context.Context, userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error)
CreateWithProfile(ctx context.Context, userID int64, profileUUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error)
Validate(ctx context.Context, accessToken, clientToken string) bool Validate(ctx context.Context, accessToken, clientToken string) bool
Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error)
Invalidate(ctx context.Context, accessToken string) Invalidate(ctx context.Context, accessToken string)
@@ -116,6 +117,7 @@ type YggdrasilService interface {
// 序列化 // 序列化
SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{}
SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{}
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
// 证书 // 证书

View File

@@ -4,6 +4,7 @@ import (
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/internal/repository" "carrotskin/internal/repository"
"carrotskin/pkg/database" "carrotskin/pkg/database"
"carrotskin/pkg/utils"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
@@ -12,7 +13,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -64,7 +64,7 @@ func (s *profileService) Create(ctx context.Context, userID int64, name string)
} }
// 生成UUID和RSA密钥 // 生成UUID和RSA密钥
profileUUID := uuid.New().String() profileUUID := utils.GenerateUUID()
privateKey, err := generateRSAPrivateKeyInternal() privateKey, err := generateRSAPrivateKeyInternal()
if err != nil { if err != nil {
return nil, fmt.Errorf("生成RSA密钥失败: %w", err) return nil, fmt.Errorf("生成RSA密钥失败: %w", err)

View File

@@ -274,3 +274,26 @@ func FormatPublicKey(publicKeyPEM string) string {
} }
return strings.Join(keyLines, "") return strings.Join(keyLines, "")
} }
// SignStringWithProfileRSA 使用Profile的RSA私钥签名字符串
func (s *SignatureService) SignStringWithProfileRSA(data string, privateKeyPEM string) (string, error) {
// 解析PEM格式的私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("解析PEM私钥失败")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析RSA私钥失败: %w", err)
}
// 签名
hashed := sha1.Sum([]byte(data))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil {
return "", fmt.Errorf("签名失败: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}

View File

@@ -4,12 +4,12 @@ import (
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/internal/repository" "carrotskin/internal/repository"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"carrotskin/pkg/utils"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time" "time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -63,9 +63,13 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
} }
} }
// 生成ClientToken // 生成ClientToken使用32字符十六进制字符串
if clientToken == "" { if clientToken == "" {
clientToken = uuid.New().String() var err error
clientToken, err = utils.RandomHex(16) // 16字节 = 32字符十六进制
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成ClientToken失败: %w", err)
}
} }
// 获取或创建Client // 获取或创建Client
@@ -73,7 +77,10 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
existingClient, err := s.clientRepo.FindByClientToken(ctx, clientToken) existingClient, err := s.clientRepo.FindByClientToken(ctx, clientToken)
if err != nil { if err != nil {
// Client不存在创建新的 // Client不存在创建新的
clientUUID := uuid.New().String() clientUUID, err := utils.RandomHex(16) // 16字节 = 32字符十六进制
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成ClientUUID失败: %w", err)
}
client = &model.Client{ client = &model.Client{
UUID: clientUUID, UUID: clientUUID,
ClientToken: clientToken, ClientToken: clientToken,
@@ -173,6 +180,11 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
return selectedProfileID, availableProfiles, accessToken, clientToken, nil return selectedProfileID, availableProfiles, accessToken, clientToken, nil
} }
// CreateWithProfile 创建Token并绑定指定Profile使用JWT + Redis存储
func (s *tokenServiceRedis) CreateWithProfile(ctx context.Context, userID int64, profileUUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
return s.Create(ctx, userID, profileUUID, clientToken)
}
// Validate 验证Token使用JWT验证 + Redis存储验证 // Validate 验证Token使用JWT验证 + Redis存储验证
func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientToken string) bool { func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientToken string) bool {
// 设置超时上下文 // 设置超时上下文

View File

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

View File

@@ -85,8 +85,8 @@ func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, ac
return fmt.Errorf("验证Token失败: %w", err) return fmt.Errorf("验证Token失败: %w", err)
} }
// 格式化UUID并验证与Token关联的配置文件 // 确保UUID是32位无符号格式用于向后兼容
formattedProfile := utils.FormatUUID(selectedProfile) formattedProfile := utils.FormatUUIDToNoDash(selectedProfile)
if uuid != formattedProfile { if uuid != formattedProfile {
return errors.New("selectedProfile与Token不匹配") return errors.New("selectedProfile与Token不匹配")
} }
@@ -115,6 +115,11 @@ func (s *yggdrasilServiceComposite) SerializeProfile(ctx context.Context, profil
return s.serializationService.SerializeProfile(ctx, profile) return s.serializationService.SerializeProfile(ctx, profile)
} }
// SerializeProfileWithUnsigned 序列化档案支持unsigned参数
func (s *yggdrasilServiceComposite) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} {
return s.serializationService.SerializeProfileWithUnsigned(ctx, profile, unsigned)
}
// SerializeUser 序列化用户 // SerializeUser 序列化用户
func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} { func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} {
return s.serializationService.SerializeUser(ctx, user, uuid) return s.serializationService.SerializeUser(ctx, user, uuid)

View File

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

View File

@@ -154,7 +154,7 @@ type TextureInfo struct {
// ProfileInfo 角色信息 // ProfileInfo 角色信息
// @Description Minecraft档案信息 // @Description Minecraft档案信息
type ProfileInfo struct { type ProfileInfo struct {
UUID string `json:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` UUID string `json:"uuid" example:"550e8400e29b41d4a716446655440000"`
UserID int64 `json:"user_id" example:"1"` UserID int64 `json:"user_id" example:"1"`
Name string `json:"name" example:"PlayerName"` Name string `json:"name" example:"PlayerName"`
SkinID *int64 `json:"skin_id,omitempty" example:"1"` SkinID *int64 `json:"skin_id,omitempty" example:"1"`

View File

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

View File

@@ -0,0 +1,332 @@
# 无符号UUID实现方案设计
## 1. 概述
### 1.1 背景
当前项目中UUID使用带连字符的标准格式36位`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`。为了与Yggdrasil协议更好的兼容并优化存储空间需要实现无符号UUID32位十六进制字符串
### 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协议的兼容性并优化存储空间。