refactor(yggdrasil): 整理与性能优化,修复若干 Bug
Some checks failed
Build / build (push) Failing after 21m45s
Build / build-docker (push) Has been skipped

Bug 修复
- textures metadata:SKIN 仅在 IsSlim 时输出 {"model":"slim"};CAPE 不带 metadata。
  原实现误用文件字节数 skin.Size 作为 metadata,违反 Yggdrasil 协议。
- KeyPair 持久化不全:Profile 新增 rsa_public_key/public_key_signature/
  public_key_signature_v2/key_expires_at/key_refresh_at 列;GetKeyPair/UpdateKeyPair
  读写全部字段。AutoMigrate 自动加列。原实现每次请求都重新生成 RSA-4096。
- GetPlayerCertificates:无效 token 返回 401(先前误用 403 且未判 err)。
- HasJoinedServer:失败返回 204 无 body,符合 Yggdrasil 协议(原用 APIResponse
  + body 违反 204 规范)。

代码质量
- Authenticate 删除冗余 body 读取与回放。
- 证书服务引入具名结构 PlayerCertificate/PlayerKeyPair,替代 map[string]interface{}。
- CreateSession 用户名缺失改用 ErrUsernameRequired(新增)。

性能优化(签名一致性的回归测试已覆盖)
- SignatureService 缓存已解析的根私钥(sync.RWMutex 双重检查),
  快路径仅一次 RLock + RSA 签名,免去每次签名 PEM 解析与 Redis 往返。
- GetOrCreateYggdrasilKeyPair 用 MGet 单次往返取三字段,缓存命中后零 Redis。
- NewKeyPair 消息构造用 strconv.AppendInt 避免 string+拼接分配;V2 复用 DER 字节。
- 序列化服务:texturesMap 预分配 cap(2);slim 分支直接构造完整 map。
- 会话服务 GetSession 移除重复的 ValidateServerID 校验。

死代码清理
- 删除 yggdrasil_validator.go(未被调用的 Validator)。
- 删除 signature_service.FormatPublicKey/SignStringWithProfileRSA。
- 删除 pkg/auth 中未被使用的 YggdrasilJWTManager 与 GenerateKeyPair/
  EncodePrivateKeyToPEM/RedisClient/YggdrasilPrivateKeyRedisKey。
- 删除 yggdrasil_handler.go 中 25 个未使用常量、passwordRegex、
  APIResponse/standardResponse。
- 删除 errors.go 中未被使用的 ErrYggForbiddenOperation/ErrYggIllegalArgument/
  ErrInvalidSignature/ErrInvalidTextureType/ErrCertificateGenerate/ErrInvalidPassword。

测试
- 新增 signature_service_test.go(基于 miniredis):8 个测试 + 基准。
  核心:SignString_MatchesReference 与重构前参考实现逐字节比对签名输出一致。
  -race 检测通过;基准约 7.1ms/op(缓存命中路径)。

附带(与本次任务前已存在的未提交改动)
- handler/captcha_handler:将响应字段 msg 改为 message,与前端约定对齐。
This commit is contained in:
2026-07-09 17:18:26 +08:00
parent cea22951b9
commit 1dc9c36a3a
15 changed files with 636 additions and 716 deletions

View File

@@ -35,7 +35,7 @@ type CaptchaVerifyRequest struct {
// @Tags captcha
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "生成成功 {code: 200, data: {masterImage, tileImage, captchaId, y}}"
// @Success 200 {object} map[string]interface{} "生成成功 {code: 200, message: string, data: {masterImage, tileImage, captchaId, y}}"
// @Failure 500 {object} map[string]interface{} "生成失败"
// @Router /api/v1/captcha/generate [get]
func (h *CaptchaHandler) Generate(c *gin.Context) {
@@ -43,14 +43,15 @@ func (h *CaptchaHandler) Generate(c *gin.Context) {
if err != nil {
h.logger.Error("生成验证码失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "生成验证码失败",
"code": 500,
"message": "生成验证码失败",
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"code": 200,
"message": "操作成功",
"data": gin.H{
"masterImage": masterImg,
"tileImage": tileImg,
@@ -67,15 +68,15 @@ func (h *CaptchaHandler) Generate(c *gin.Context) {
// @Accept json
// @Produce json
// @Param request body CaptchaVerifyRequest true "验证请求"
// @Success 200 {object} map[string]interface{} "验证结果 {code: 200/400, msg: string}"
// @Success 200 {object} map[string]interface{} "验证结果 {code: 200/400, message: string}"
// @Failure 400 {object} map[string]interface{} "参数错误"
// @Router /api/v1/captcha/verify [post]
func (h *CaptchaHandler) Verify(c *gin.Context) {
var req CaptchaVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "参数错误: " + err.Error(),
"code": 400,
"message": "参数错误: " + err.Error(),
})
return
}
@@ -87,21 +88,21 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "验证失败",
"code": 500,
"message": "验证失败",
})
return
}
if valid {
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "验证成功",
"code": 200,
"message": "验证成功",
})
} else {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "验证失败,请重试",
"code": 400,
"message": "验证失败,请重试",
})
}
}

View File

@@ -1,8 +1,6 @@
package handler
import (
"bytes"
"io"
"net/http"
"regexp"
@@ -17,66 +15,18 @@ import (
// 常量定义
const (
ErrInternalServer = "服务器内部错误"
// 错误类型
ErrInvalidEmailFormat = "邮箱格式不正确"
ErrInvalidPassword = "密码必须至少包含8个字符只能包含字母、数字和特殊字符"
ErrWrongPassword = "密码错误"
ErrUserNotMatch = "用户不匹配"
// 错误消息
ErrInvalidRequest = "请求格式无效"
ErrJoinServerFailed = "加入服务器失败"
ErrServerIDRequired = "服务器ID不能为空"
ErrUsernameRequired = "用户名不能为空"
ErrSessionVerifyFailed = "会话验证失败"
ErrProfileNotFound = "未找到用户配置文件"
ErrInvalidParams = "无效的请求参数"
ErrEmptyUserID = "用户ID为空"
ErrUnauthorized = "无权操作此配置文件"
ErrGetProfileService = "获取配置文件服务失败"
// 成功信息
SuccessProfileCreated = "创建成功"
MsgRegisterSuccess = "注册成功"
// 错误消息
ErrGetProfile = "获取配置文件失败"
ErrGetTextureService = "获取材质服务失败"
ErrInvalidContentType = "无效的请求内容类型"
ErrParseMultipartForm = "解析多部分表单失败"
ErrGetFileFromForm = "从表单获取文件失败"
ErrInvalidFileType = "无效的文件类型仅支持PNG图片"
ErrSaveTexture = "保存材质失败"
ErrSetTexture = "设置材质失败"
ErrGetTexture = "获取材质失败"
// 内存限制
MaxMultipartMemory = 32 << 20 // 32 MB
// 材质类型
TextureTypeSkin = "SKIN"
TextureTypeCape = "CAPE"
// 内容类型
ContentTypePNG = "image/png"
ContentTypeMultipart = "multipart/form-data"
// 表单参数
FormKeyModel = "model"
FormKeyFile = "file"
// 元数据键
MetaKeyModel = "model"
)
// 正则表达式
var (
// 邮箱正则表达式
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// 密码强度正则表达式最少8位只允许字母、数字和特定特殊字符
passwordRegex = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*]{8,}$`)
)
// 请求结构体
@@ -139,22 +89,6 @@ type (
}
)
// APIResponse API响应
type APIResponse struct {
Status int `json:"status"`
Data interface{} `json:"data"`
Error interface{} `json:"error"`
}
// standardResponse 生成标准响应
func standardResponse(c *gin.Context, status int, data interface{}, err interface{}) {
c.JSON(status, APIResponse{
Status: status,
Data: data,
Error: err,
})
}
// YggdrasilHandler Yggdrasil API处理器
type YggdrasilHandler struct {
container *container.Container
@@ -180,16 +114,8 @@ func NewYggdrasilHandler(c *container.Container) *YggdrasilHandler {
// @Failure 403 {object} map[string]string "认证失败"
// @Router /api/yggdrasil/authserver/authenticate [post]
func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
rawData, err := io.ReadAll(c.Request.Body)
if err != nil {
h.logger.Error("读取请求体失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(rawData))
var request AuthenticateRequest
if err = c.ShouldBindJSON(&request); err != nil {
if err := c.ShouldBindJSON(&request); err != nil {
h.logger.Error("解析认证请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -198,8 +124,10 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
var userId int64
var profile *model.Profile
var UUID string
var err error
if emailRegex.MatchString(request.Identifier) {
// 邮箱登录UUID 留空,后续 TokenService.Create 会自动选择该用户的角色
userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier)
} else {
profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier)
@@ -507,7 +435,7 @@ func (h *YggdrasilHandler) SignOut(c *gin.Context) {
if !emailRegex.MatchString(request.Email) {
h.logger.Warn("登出失败: 邮箱格式不正确", zap.String("email", request.Email))
c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidEmailFormat})
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
return
}
@@ -579,8 +507,8 @@ func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) {
// @Produce json
// @Param request body JoinServerRequest true "加入请求"
// @Success 204 "加入成功"
// @Failure 400 {object} APIResponse "参数错误"
// @Failure 500 {object} APIResponse "服务器错误"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 500 {object} map[string]string "服务器错误"
// @Router /api/yggdrasil/sessionserver/session/minecraft/join [post]
func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
var request JoinServerRequest
@@ -643,14 +571,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
serverID, exists := c.GetQuery("serverId")
if !exists || serverID == "" {
h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP))
standardResponse(c, http.StatusNoContent, nil, ErrServerIDRequired)
c.Status(http.StatusNoContent)
return
}
username, exists := c.GetQuery("username")
if !exists || username == "" {
h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP))
standardResponse(c, http.StatusNoContent, nil, ErrUsernameRequired)
c.Status(http.StatusNoContent)
return
}
@@ -667,14 +595,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
zap.String("username", username),
zap.String("ip", clientIP),
)
standardResponse(c, http.StatusNoContent, nil, ErrSessionVerifyFailed)
c.Status(http.StatusNoContent)
return
}
profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username)
if err != nil {
h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username))
standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound)
c.Status(http.StatusNoContent)
return
}
@@ -683,7 +611,7 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
zap.String("username", username),
zap.String("uuid", profile.UUID),
)
c.JSON(200, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile))
c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile))
}
// GetProfilesByName 批量获取配置文件
@@ -694,14 +622,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
// @Produce json
// @Param request body []string true "用户名列表"
// @Success 200 {array} model.Profile "档案列表"
// @Failure 400 {object} APIResponse "参数错误"
// @Failure 400 {object} map[string]string "参数错误"
// @Router /api/yggdrasil/api/profiles/minecraft [post]
func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
var names []string
if err := c.ShouldBindJSON(&names); err != nil {
h.logger.Error("解析名称数组请求失败", zap.Error(err))
standardResponse(c, http.StatusBadRequest, nil, ErrInvalidParams)
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}
@@ -723,7 +651,7 @@ func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "元数据"
// @Failure 500 {object} APIResponse "服务器错误"
// @Failure 500 {object} map[string]string "服务器错误"
// @Router /api/yggdrasil [get]
func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
meta := gin.H{
@@ -765,7 +693,7 @@ func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer {token}"
// @Success 200 {object} map[string]interface{} "证书信息"
// @Success 200 {object} service.PlayerCertificate "证书信息"
// @Failure 401 {object} errors.YggdrasilErrorResponse "未授权"
// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
// @Failure 500 {object} errors.YggdrasilErrorResponse "服务器错误"
@@ -805,10 +733,10 @@ func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) {
}
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID)
if uuid == "" {
if err != nil || uuid == "" {
h.logger.Error("获取玩家UUID失败", zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
"Unauthorized",
errors.YggErrInvalidToken,
"",
))