Files
backend/internal/handler/yggdrasil_handler.go
lan f2b02682c2
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled
feat(yggdrasil): 实现好友系统与 profiles 查询接口
按 MinecraftServices 文档实现好友系四组接口与 profiles 服务:

好友系统(/api/yggdrasil/minecraftservices/*)
- Friends(1.3):列表查询、ADD/REMOVE 操作(发请求/接受/拒绝/撤回/删除)
  - 状态枚举+单向记录模型,接受请求时合并两方向避免好友列表重复
- Player Attributes(1.2):friendsPreferences / 脏词过滤 / 聊天偏好读写
  - Upsert 用 map 显式写字段规避 gorm 对带 default 零值布尔字段的忽略
- Presence(1.1):Redis key+TTL 上报与好友在线状态批量查询
- Blocklist(1.6):屏蔽列表查询(含 120s Redis 缓存),Block/Unblock 内部方法

Profiles 服务
- getManyByName(2.1):POST /api/profiles/minecraft,返回 [{id,name}]
  - toLowerCase 规范化、去重、空名过滤、maxBatch=10 超限拒绝
- getByName(2.2):GET /api/users/profiles/minecraft/:name,返回 {id,name},未找到返回 404

路由与基础设施
- 路由按官方 host 前缀一一转发
  - api.mojang.com/* -> /api/yggdrasil/api/*
  - api.minecraftservices.com/* -> /api/yggdrasil/minecraftservices/*
- 新增 Friend/PlayerAttributes 模型与 AutoMigrate 注册
- 新增 FriendsService 与 Container 装配
- 抽 extractBearerToken helper,统一 MinecraftServices 错误响应

修复
- texture_service: ToggleFavorite 在 db 为 nil 时降级非事务执行
  修复 TestTextureServiceImpl_ToggleFavorite 空指针 panic
- texture_service_test: UploadTexture 用例改用真实 SHA-256 命中 mock
  修复 4 个子用例因文件大小/Hash 不匹配的失败
- profile_repository: GetByNames/FindByName 改 LOWER() 大小写不敏感
  与客户端 toLowerCase 规范化对齐

测试
- 好友模型、ADD/REMOVE/接受、属性、屏蔽、Bearer 解析单测全绿
- profiles 查询规范化、去重、超限、大小写不敏感单测全绿
- go build ./... && go test ./... 全部通过
2026-07-09 20:44:41 +08:00

800 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"net/http"
"regexp"
"carrotskin/internal/container"
"carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/pkg/utils"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// 常量定义
const (
// 内存限制
MaxMultipartMemory = 32 << 20 // 32 MB
// 材质类型
TextureTypeSkin = "SKIN"
TextureTypeCape = "CAPE"
)
// 正则表达式
var (
// 邮箱正则表达式
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
)
// 请求结构体
type (
// AuthenticateRequest 认证请求
AuthenticateRequest struct {
Agent map[string]interface{} `json:"agent"`
ClientToken string `json:"clientToken"`
Identifier string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
RequestUser bool `json:"requestUser"`
}
// ValidTokenRequest 验证令牌请求
ValidTokenRequest struct {
AccessToken string `json:"accessToken" binding:"required"`
ClientToken string `json:"clientToken"`
}
// RefreshRequest 刷新令牌请求
RefreshRequest struct {
AccessToken string `json:"accessToken" binding:"required"`
ClientToken string `json:"clientToken"`
RequestUser bool `json:"requestUser"`
SelectedProfile map[string]interface{} `json:"selectedProfile"`
}
// SignOutRequest 登出请求
SignOutRequest struct {
Email string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// JoinServerRequest 加入服务器请求
JoinServerRequest struct {
ServerID string `json:"serverId" binding:"required"`
AccessToken string `json:"accessToken" binding:"required"`
SelectedProfile string `json:"selectedProfile" binding:"required"`
}
)
// 响应结构体
type (
// AuthenticateResponse 认证响应
AuthenticateResponse struct {
AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"`
SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"`
AvailableProfiles []map[string]interface{} `json:"availableProfiles"`
User map[string]interface{} `json:"user,omitempty"`
}
// RefreshResponse 刷新令牌响应
RefreshResponse struct {
AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"`
SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"`
AvailableProfiles []map[string]interface{} `json:"availableProfiles"`
User map[string]interface{} `json:"user,omitempty"`
}
)
// YggdrasilHandler Yggdrasil API处理器
type YggdrasilHandler struct {
container *container.Container
logger *zap.Logger
}
// NewYggdrasilHandler 创建YggdrasilHandler实例
func NewYggdrasilHandler(c *container.Container) *YggdrasilHandler {
return &YggdrasilHandler{
container: c,
logger: c.Logger,
}
}
// Authenticate 用户认证
// @Summary Yggdrasil认证
// @Description Yggdrasil协议: 用户登录认证
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body AuthenticateRequest true "认证请求"
// @Success 200 {object} AuthenticateResponse
// @Failure 403 {object} map[string]string "认证失败"
// @Router /api/yggdrasil/authserver/authenticate [post]
func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
var request AuthenticateRequest
if err := c.ShouldBindJSON(&request); err != nil {
h.logger.Error("解析认证请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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)
if err != nil {
h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidCredentials,
"",
))
return
}
userId = profile.UserID
UUID = profile.UUID
}
if err != nil {
h.logger.Warn("认证失败: 用户不存在", zap.String("identifier", request.Identifier), zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidCredentials,
"",
))
return
}
if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, userId); err != nil {
h.logger.Warn("认证失败: 密码错误", zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidCredentials,
"",
))
return
}
selectedProfile, availableProfiles, accessToken, clientToken, err := h.container.TokenService.Create(c.Request.Context(), userId, UUID, request.ClientToken)
if err != nil {
h.logger.Error("生成令牌失败", zap.Error(err), zap.Int64("userId", userId))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
user, err := h.container.UserService.GetByID(c.Request.Context(), userId)
if err != nil {
h.logger.Error("获取用户信息失败", zap.Error(err), zap.Int64("userId", userId))
}
availableProfilesData := make([]map[string]interface{}, 0, len(availableProfiles))
for _, p := range availableProfiles {
availableProfilesData = append(availableProfilesData, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *p))
}
response := AuthenticateResponse{
AccessToken: accessToken,
ClientToken: clientToken,
AvailableProfiles: availableProfilesData,
}
if selectedProfile != nil {
response.SelectedProfile = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *selectedProfile)
}
if request.RequestUser && user != nil {
response.User = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, UUID)
}
h.logger.Info("用户认证成功", zap.Int64("userId", userId))
c.JSON(http.StatusOK, response)
}
// ValidToken 验证令牌
// @Summary Yggdrasil验证令牌
// @Description Yggdrasil协议: 验证AccessToken是否有效
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body ValidTokenRequest true "验证请求"
// @Success 204 "令牌有效"
// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
// @Router /api/yggdrasil/authserver/validate [post]
func (h *YggdrasilHandler) ValidToken(c *gin.Context) {
var request ValidTokenRequest
if err := c.ShouldBindJSON(&request); err != nil {
h.logger.Error("解析验证令牌请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if h.container.TokenService.Validate(c.Request.Context(), request.AccessToken, request.ClientToken) {
h.logger.Info("令牌验证成功", zap.String("accessToken", request.AccessToken))
c.JSON(http.StatusNoContent, gin.H{})
} else {
h.logger.Warn("令牌验证失败", zap.String("accessToken", request.AccessToken))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
}
}
// RefreshToken 刷新令牌
// @Summary Yggdrasil刷新令牌
// @Description Yggdrasil协议: 刷新AccessToken
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body RefreshRequest true "刷新请求"
// @Success 200 {object} RefreshResponse
// @Failure 400 {object} map[string]string "刷新失败"
// @Router /api/yggdrasil/authserver/refresh [post]
func (h *YggdrasilHandler) RefreshToken(c *gin.Context) {
var request RefreshRequest
if err := c.ShouldBindJSON(&request); err != nil {
h.logger.Error("解析刷新令牌请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 获取用户ID和当前绑定的Profile UUID
userID, err := h.container.TokenService.GetUserIDByAccessToken(c.Request.Context(), request.AccessToken)
if err != nil {
h.logger.Warn("刷新令牌失败: 无效的访问令牌", zap.String("token", request.AccessToken), zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return
}
currentUUID, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), request.AccessToken)
if err != nil {
h.logger.Warn("刷新令牌失败: 无法获取当前Profile", zap.String("token", request.AccessToken), zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return
}
// 获取用户的所有可用profiles
availableProfiles, err := h.container.ProfileService.GetByUserID(c.Request.Context(), userID)
if err != nil {
h.logger.Error("刷新令牌失败: 无法获取用户profiles", zap.Error(err), zap.Int64("userId", userID))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var profileData map[string]interface{}
var userData map[string]interface{}
var profileID string
// 处理selectedProfile
if request.SelectedProfile != nil {
profileIDValue, ok := request.SelectedProfile["id"]
if !ok {
h.logger.Error("刷新令牌失败: 缺少配置文件ID", zap.Int64("userId", userID))
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少配置文件ID"})
return
}
profileID, ok = profileIDValue.(string)
if !ok {
h.logger.Error("刷新令牌失败: 配置文件ID类型错误", zap.Int64("userId", userID))
c.JSON(http.StatusBadRequest, gin.H{"error": "配置文件ID必须是字符串"})
return
}
// 确保profileID是32位无符号格式用于向后兼容
profileID = utils.FormatUUIDToNoDash(profileID)
// 根据Yggdrasil规范当指定selectedProfile时原令牌所绑定的角色必须为空
// 如果原令牌已绑定角色,则不允许更换角色,但允许选择相同的角色以兼容部分启动器
if currentUUID != "" && currentUUID != profileID {
h.logger.Warn("刷新令牌失败: 令牌已绑定其他角色,不允许更换",
zap.Int64("userId", userID),
zap.String("currentUUID", currentUUID),
zap.String("requestedUUID", profileID),
)
c.JSON(http.StatusBadRequest, errors.NewYggdrasilErrorResponse(
"IllegalArgumentException",
errors.YggErrProfileAlreadyAssigned,
"",
))
return
}
// 验证profile是否属于该用户
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), profileID)
if err != nil {
h.logger.Warn("刷新令牌失败: Profile不存在", zap.String("profileId", profileID), zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return
}
if profile.UserID != userID {
h.logger.Warn("刷新令牌失败: 用户不匹配",
zap.Int64("userId", userID),
zap.Int64("profileUserId", profile.UserID),
)
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return
}
profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)
} else {
// 如果未指定selectedProfile使用当前token绑定的profile如果存在
if currentUUID != "" {
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), currentUUID)
if err == nil && profile != nil {
profileID = currentUUID
profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)
}
}
}
// 获取用户信息(如果请求)
user, _ := h.container.UserService.GetByID(c.Request.Context(), userID)
if request.RequestUser && user != nil {
userData = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, currentUUID)
}
// 刷新token
newAccessToken, newClientToken, err := h.container.TokenService.Refresh(c.Request.Context(),
request.AccessToken,
request.ClientToken,
profileID,
)
if err != nil {
h.logger.Error("刷新令牌失败", zap.Error(err), zap.Int64("userId", userID))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return
}
// 序列化可用profiles
availableProfilesData := make([]map[string]interface{}, 0, len(availableProfiles))
for _, p := range availableProfiles {
availableProfilesData = append(availableProfilesData, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *p))
}
h.logger.Info("刷新令牌成功", zap.Int64("userId", userID))
c.JSON(http.StatusOK, RefreshResponse{
AccessToken: newAccessToken,
ClientToken: newClientToken,
SelectedProfile: profileData,
AvailableProfiles: availableProfilesData,
User: userData,
})
}
// InvalidToken 使令牌失效
// @Summary Yggdrasil注销令牌
// @Description Yggdrasil协议: 使AccessToken失效
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body ValidTokenRequest true "失效请求"
// @Success 204 "操作成功"
// @Router /api/yggdrasil/authserver/invalidate [post]
func (h *YggdrasilHandler) InvalidToken(c *gin.Context) {
var request ValidTokenRequest
if err := c.ShouldBindJSON(&request); err != nil {
h.logger.Error("解析使令牌失效请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.container.TokenService.Invalidate(c.Request.Context(), request.AccessToken)
h.logger.Info("令牌已失效", zap.String("token", request.AccessToken))
c.JSON(http.StatusNoContent, gin.H{})
}
// SignOut 用户登出
// @Summary Yggdrasil登出
// @Description Yggdrasil协议: 用户登出,使所有令牌失效
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body SignOutRequest true "登出请求"
// @Success 204 "操作成功"
// @Failure 400 {object} map[string]string "参数错误"
// @Router /api/yggdrasil/authserver/signout [post]
func (h *YggdrasilHandler) SignOut(c *gin.Context) {
var request SignOutRequest
if err := c.ShouldBindJSON(&request); err != nil {
h.logger.Error("解析登出请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !emailRegex.MatchString(request.Email) {
h.logger.Warn("登出失败: 邮箱格式不正确", zap.String("email", request.Email))
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
return
}
user, err := h.container.UserService.GetByEmail(c.Request.Context(), request.Email)
if err != nil || user == nil {
h.logger.Warn("登出失败: 用户不存在", zap.String("email", request.Email), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在"})
return
}
if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, user.ID); err != nil {
h.logger.Warn("登出失败: 密码错误", zap.Int64("userId", user.ID))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidCredentials,
"",
))
return
}
h.container.TokenService.InvalidateUserTokens(c.Request.Context(), user.ID)
h.logger.Info("用户登出成功", zap.Int64("userId", user.ID))
c.JSON(http.StatusNoContent, gin.H{"valid": true})
}
// GetProfileByUUID 根据UUID获取档案
// @Summary Yggdrasil获取档案
// @Description Yggdrasil协议: 根据UUID获取用户档案信息
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param uuid path string true "用户UUID"
// @Param unsigned query string false "是否不返回签名true/false默认false"
// @Success 200 {object} map[string]interface{} "档案信息"
// @Failure 404 {object} errors.YggdrasilErrorResponse "档案不存在"
// @Router /api/yggdrasil/sessionserver/session/minecraft/profile/{uuid} [get]
func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) {
uuid := c.Param("uuid")
h.logger.Info("获取配置文件请求", zap.String("uuid", uuid))
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), uuid)
if err != nil {
h.logger.Error("获取配置文件失败", zap.Error(err), zap.String("uuid", uuid))
c.JSON(http.StatusNotFound, errors.NewYggdrasilErrorResponse(
"Not Found",
"Profile not found",
"",
))
return
}
// 读取 unsigned 查询参数
unsignedParam := c.DefaultQuery("unsigned", "false")
unsigned := unsignedParam == "true"
h.logger.Info("成功获取配置文件",
zap.String("uuid", uuid),
zap.String("name", profile.Name),
zap.Bool("unsigned", unsigned))
c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfileWithUnsigned(c.Request.Context(), *profile, unsigned))
}
// JoinServer 加入服务器
// @Summary Yggdrasil加入服务器
// @Description Yggdrasil协议: 客户端加入服务器
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body JoinServerRequest true "加入请求"
// @Success 204 "加入成功"
// @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
clientIP := c.ClientIP()
if err := c.ShouldBindJSON(&request); err != nil {
h.logger.Error("解析加入服务器请求失败", zap.Error(err), zap.String("ip", clientIP))
c.JSON(http.StatusBadRequest, errors.NewYggdrasilErrorResponse(
"Bad Request",
"Invalid request format",
"",
))
return
}
h.logger.Info("收到加入服务器请求",
zap.String("serverId", request.ServerID),
zap.String("userUUID", request.SelectedProfile),
zap.String("ip", clientIP),
)
if err := h.container.YggdrasilService.JoinServer(c.Request.Context(), request.ServerID, request.AccessToken, request.SelectedProfile, clientIP); err != nil {
h.logger.Error("加入服务器失败",
zap.Error(err),
zap.String("serverId", request.ServerID),
zap.String("userUUID", request.SelectedProfile),
zap.String("ip", clientIP),
)
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException",
errors.YggErrInvalidToken,
"",
))
return
}
h.logger.Info("加入服务器成功",
zap.String("serverId", request.ServerID),
zap.String("userUUID", request.SelectedProfile),
zap.String("ip", clientIP),
)
c.Status(http.StatusNoContent)
}
// HasJoinedServer 验证玩家是否已加入服务器
// @Summary Yggdrasil验证加入
// @Description Yggdrasil协议: 服务端验证客户端是否已加入
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param username query string true "用户名"
// @Param serverId query string true "服务器ID"
// @Param ip query string false "客户端IP"
// @Success 200 {object} map[string]interface{} "验证成功,返回档案"
// @Failure 204 "验证失败"
// @Router /api/yggdrasil/sessionserver/session/minecraft/hasJoined [get]
func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
clientIP, _ := c.GetQuery("ip")
serverID, exists := c.GetQuery("serverId")
if !exists || serverID == "" {
h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP))
c.Status(http.StatusNoContent)
return
}
username, exists := c.GetQuery("username")
if !exists || username == "" {
h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP))
c.Status(http.StatusNoContent)
return
}
h.logger.Info("收到会话验证请求",
zap.String("serverId", serverID),
zap.String("username", username),
zap.String("ip", clientIP),
)
if err := h.container.YggdrasilService.HasJoinedServer(c.Request.Context(), serverID, username, clientIP); err != nil {
h.logger.Warn("会话验证失败",
zap.Error(err),
zap.String("serverId", serverID),
zap.String("username", username),
zap.String("ip", clientIP),
)
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))
c.Status(http.StatusNoContent)
return
}
h.logger.Info("会话验证成功",
zap.String("serverId", serverID),
zap.String("username", username),
zap.String("uuid", profile.UUID),
)
c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile))
}
// GetProfilesByName 批量获取配置文件
// @Summary Yggdrasil批量获取档案
// @Description Yggdrasil协议: 根据名称批量获取用户档案(文档 2.1,每页最多 10 个,返回 [{id,name}]
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body []string true "用户名列表"
// @Success 200 {array} model.NameAndId "档案标识列表"
// @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))
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}
h.logger.Info("接收到批量获取配置文件请求", zap.Int("count", len(names)))
// 文档 2.1:每页最多 10 个用户名
const maxBatch = 10
results, err := h.container.ProfileService.ProfileSearchByName(c.Request.Context(), names, maxBatch)
if err != nil {
h.logger.Warn("批量获取配置文件失败", zap.Error(err), zap.Int("count", len(names)))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.logger.Info("成功获取配置文件", zap.Int("requested", len(names)), zap.Int("returned", len(results)))
c.JSON(http.StatusOK, results)
}
// GetProfileByName 单个用户名查档案
// @Summary Yggdrasil按用户名查档案
// @Description Yggdrasil协议: 根据单个用户名查询用户档案(文档 2.2,返回 {id,name}
// @Tags Yggdrasil
// @Produce json
// @Param name path string true "用户名"
// @Success 200 {object} model.NameAndId "档案标识"
// @Failure 404 {object} map[string]string "档案不存在"
// @Router /api/yggdrasil/api/users/profiles/minecraft/{name} [get]
func (h *YggdrasilHandler) GetProfileByName(c *gin.Context) {
name := c.Param("name")
if name == "" {
h.logger.Warn("缺少用户名参数")
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
result, err := h.container.ProfileService.ProfileSearchByNameSingle(c.Request.Context(), name)
if err != nil {
// 按文档契约:任意错误视为未找到
h.logger.Warn("按用户名查档失败", zap.String("name", name), zap.Error(err))
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
if result == nil {
h.logger.Info("用户名未找到", zap.String("name", name))
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
h.logger.Info("成功获取档案", zap.String("name", name), zap.String("uuid", result.ID))
c.JSON(http.StatusOK, result)
}
// GetMetaData 获取Yggdrasil元数据
// @Summary Yggdrasil元数据
// @Description Yggdrasil协议: 获取服务器元数据
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "元数据"
// @Failure 500 {object} map[string]string "服务器错误"
// @Router /api/yggdrasil [get]
func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
meta := gin.H{
"implementationName": "CellAuth",
"implementationVersion": "0.0.1",
"serverName": "LittleLan's Yggdrasil Server Implementation.",
"links": gin.H{
"homepage": "https://skin.littlelan.cn",
"register": "https://skin.littlelan.cn/auth",
},
"feature.non_email_login": true,
"feature.enable_profile_key": true,
}
skinDomains := []string{".hitwh.games", ".littlelan.cn"}
signature, err := h.container.YggdrasilService.GetPublicKey(c.Request.Context())
if err != nil {
h.logger.Error("获取公钥失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, errors.NewYggdrasilErrorResponse(
"Internal Server Error",
"Failed to get public key",
"",
))
return
}
h.logger.Info("提供元数据")
c.JSON(http.StatusOK, gin.H{
"meta": meta,
"skinDomains": skinDomains,
"signaturePublickey": signature,
})
}
// GetPlayerCertificates 获取玩家证书
// @Summary Yggdrasil获取证书
// @Description Yggdrasil协议: 获取玩家证书
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer {token}"
// @Success 200 {object} service.PlayerCertificate "证书信息"
// @Failure 401 {object} errors.YggdrasilErrorResponse "未授权"
// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
// @Failure 500 {object} errors.YggdrasilErrorResponse "服务器错误"
// @Router /api/yggdrasil/minecraftservices/player/certificates [post]
func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
"Unauthorized",
"Authorization header not provided",
"",
))
c.Abort()
return
}
bearerPrefix := "Bearer "
if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
"Unauthorized",
"Invalid Authorization format",
"",
))
c.Abort()
return
}
tokenID := authHeader[len(bearerPrefix):]
if tokenID == "" {
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
"Unauthorized",
"Invalid Authorization format",
"",
))
c.Abort()
return
}
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID)
if err != nil || uuid == "" {
h.logger.Error("获取玩家UUID失败", zap.Error(err))
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
"Unauthorized",
errors.YggErrInvalidToken,
"",
))
return
}
// UUID已经是32位无符号格式无需转换
certificate, err := h.container.YggdrasilService.GeneratePlayerCertificate(c.Request.Context(), uuid)
if err != nil {
h.logger.Error("生成玩家证书失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, errors.NewYggdrasilErrorResponse(
"Internal Server Error",
"Failed to generate player certificate",
"",
))
return
}
h.logger.Info("成功生成玩家证书")
c.JSON(http.StatusOK, certificate)
}