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
This commit is contained in:
@@ -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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -132,6 +133,7 @@ type (
|
|||||||
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"`
|
||||||
|
AvailableProfiles []map[string]interface{} `json:"availableProfiles"`
|
||||||
User map[string]interface{} `json:"user,omitempty"`
|
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,15 +440,26 @@ 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,
|
||||||
|
AvailableProfiles: availableProfilesData,
|
||||||
User: userData,
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|
||||||
// 证书
|
// 证书
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
// 设置超时上下文
|
// 设置超时上下文
|
||||||
|
|||||||
@@ -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,7 +106,11 @@ func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
textureData := base64.StdEncoding.EncodeToString(bytes)
|
textureData := base64.StdEncoding.EncodeToString(bytes)
|
||||||
signature, err := s.signatureService.SignStringWithSHA1withRSA(textureData)
|
|
||||||
|
// 只有在 unsigned=false 时才签名
|
||||||
|
var signature string
|
||||||
|
if !unsigned {
|
||||||
|
signature, err = s.signatureService.SignStringWithSHA1withRSA(textureData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("签名textures失败",
|
s.logger.Error("签名textures失败",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -107,18 +118,24 @@ func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, pr
|
|||||||
)
|
)
|
||||||
return nil
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -57,18 +57,40 @@ 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 验证访问令牌
|
||||||
func (v *Validator) ValidateAccessToken(token string) error {
|
func (v *Validator) ValidateAccessToken(token string) error {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 生成无符号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
|
||||||
|
}
|
||||||
|
|||||||
332
plans/unsigned_uuid_design.md
Normal file
332
plans/unsigned_uuid_design.md
Normal file
@@ -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协议的兼容性,并优化存储空间。
|
||||||
Reference in New Issue
Block a user