Files
backend/internal/handler/yggdrasil_friends_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

244 lines
8.3 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 (
"errors"
"net/http"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/container"
"carrotskin/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// YggdrasilFriendsHandler MinecraftServices 好友系接口处理器(文档 1.1~1.3、1.6
type YggdrasilFriendsHandler struct {
container *container.Container
logger *zap.Logger
}
// NewYggdrasilFriendsHandler 创建 YggdrasilFriendsHandler 实例
func NewYggdrasilFriendsHandler(c *container.Container) *YggdrasilFriendsHandler {
return &YggdrasilFriendsHandler{
container: c,
logger: c.Logger,
}
}
// extractProfileUUID 解析 Bearer token 得到当前请求者的 Profile UUID
// 失败时已写入 401 响应
func (h *YggdrasilFriendsHandler) extractProfileUUID(c *gin.Context) (string, bool) {
token, ok := extractBearerToken(c)
if !ok {
return "", false
}
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), token)
if err != nil || uuid == "" {
h.logger.Warn("好友接口鉴权失败: 无效令牌", zap.Error(err))
c.JSON(http.StatusUnauthorized, apperrors.NewMinecraftServicesErrorResponse(
c.Request.URL.Path, "UNAUTHORIZED", "Invalid token", nil,
))
return "", false
}
return uuid, true
}
// respondFriendsError 将 service 错误映射为 MinecraftServices 标准错误响应
func (h *YggdrasilFriendsHandler) respondFriendsError(c *gin.Context, err error) {
var appErr *apperrors.AppError
if errors.As(err, &appErr) {
// 未知 profile / 不能加自己 -> 400
if appErr.Code == 400 {
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
c.Request.URL.Path, appErr.Message, appErr.Message, nil,
))
return
}
if appErr.Code == 403 {
c.JSON(http.StatusForbidden, apperrors.NewMinecraftServicesErrorResponse(
c.Request.URL.Path, "FORBIDDEN", appErr.Message, nil,
))
return
}
}
h.logger.Error("好友接口内部错误", zap.Error(err))
c.JSON(http.StatusInternalServerError, apperrors.NewMinecraftServicesErrorResponse(
c.Request.URL.Path, "SERVICE_NOT_AVAILABLE", "Internal server error", nil,
))
}
// GetFriends 获取好友列表
// @Summary MinecraftServices 好友列表
// @Description 拉取好友列表与收到/发出的好友请求(文档 1.3.1
// @Tags Yggdrasil
// @Produce json
// @Param Authorization header string true "Bearer {accessToken}"
// @Success 200 {object} service.FriendsListResponse
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
// @Failure 500 {object} apperrors.MinecraftServicesErrorResponse "服务器错误"
// @Router /api/yggdrasil/minecraftservices/friends [get]
func (h *YggdrasilFriendsHandler) GetFriends(c *gin.Context) {
uuid, ok := h.extractProfileUUID(c)
if !ok {
return
}
resp, err := h.container.FriendsService.ListFriends(c.Request.Context(), uuid)
if err != nil {
h.respondFriendsError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
// UpdateFriends 添加/删除好友、接受/拒绝/撤回请求
// @Summary MinecraftServices 好友操作
// @Description 通过 updateType=ADD/REMOVE 进行好友加删、接受/拒绝/撤回请求(文档 1.3.2
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer {accessToken}"
// @Param request body service.FriendActionRequest true "好友操作请求"
// @Success 200 {object} service.FriendsListResponse
// @Failure 400 {object} apperrors.MinecraftServicesErrorResponse "参数错误"
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
// @Router /api/yggdrasil/minecraftservices/friends [put]
func (h *YggdrasilFriendsHandler) UpdateFriends(c *gin.Context) {
uuid, ok := h.extractProfileUUID(c)
if !ok {
return
}
var req service.FriendActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
c.Request.URL.Path, "UNKNOWN_PROFILE", "Invalid request body", nil,
))
return
}
resp, err := h.container.FriendsService.PerformFriendAction(c.Request.Context(), uuid, req)
if err != nil {
h.respondFriendsError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
// GetAttributes 获取玩家属性
// @Summary MinecraftServices 玩家属性
// @Description 拉取好友/脏词过滤/聊天偏好属性(文档 1.2.1
// @Tags Yggdrasil
// @Produce json
// @Param Authorization header string true "Bearer {accessToken}"
// @Success 200 {object} service.PlayerAttributesResponse
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
// @Router /api/yggdrasil/minecraftservices/player/attributes [get]
func (h *YggdrasilFriendsHandler) GetAttributes(c *gin.Context) {
uuid, ok := h.extractProfileUUID(c)
if !ok {
return
}
resp, err := h.container.FriendsService.GetAttributes(c.Request.Context(), uuid)
if err != nil {
h.respondFriendsError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
// UpdateAttributes 更新玩家偏好属性
// @Summary MinecraftServices 更新玩家属性
// @Description 更新好友/脏词过滤偏好(文档 1.2.2
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer {accessToken}"
// @Param request body service.PlayerAttributesRequest true "偏好更新请求"
// @Success 200 {object} service.PlayerAttributesResponse
// @Failure 400 {object} apperrors.MinecraftServicesErrorResponse "参数错误"
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
// @Router /api/yggdrasil/minecraftservices/player/attributes [post]
func (h *YggdrasilFriendsHandler) UpdateAttributes(c *gin.Context) {
uuid, ok := h.extractProfileUUID(c)
if !ok {
return
}
var req service.PlayerAttributesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
c.Request.URL.Path, "UNKNOWN_PROFILE", "Invalid request body", nil,
))
return
}
if err := h.container.FriendsService.UpdateAttributes(c.Request.Context(), uuid, req); err != nil {
h.respondFriendsError(c, err)
return
}
// 返回最新属性
resp, err := h.container.FriendsService.GetAttributes(c.Request.Context(), uuid)
if err != nil {
h.respondFriendsError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
// UpdatePresence 上报在线状态
// @Summary MinecraftServices 在线状态
// @Description 上报当前在线状态并返回好友在线状态列表(文档 1.1
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer {accessToken}"
// @Param request body service.PresenceRequest true "状态上报请求"
// @Success 200 {object} service.PresenceResponse
// @Failure 400 {object} apperrors.MinecraftServicesErrorResponse "参数错误"
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
// @Router /api/yggdrasil/minecraftservices/presence [post]
func (h *YggdrasilFriendsHandler) UpdatePresence(c *gin.Context) {
uuid, ok := h.extractProfileUUID(c)
if !ok {
return
}
var req service.PresenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
c.Request.URL.Path, "INVALID_STATUS", "Invalid request body", nil,
))
return
}
if req.Status == "" {
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
c.Request.URL.Path, "INVALID_STATUS", "Bad status value", nil,
))
return
}
resp, err := h.container.FriendsService.UpdatePresence(c.Request.Context(), uuid, req.Status)
if err != nil {
h.respondFriendsError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
// GetBlocklist 获取玩家屏蔽列表
// @Summary MinecraftServices 屏蔽列表
// @Description 拉取被屏蔽玩家档案 UUID 列表(文档 1.6
// @Tags Yggdrasil
// @Produce json
// @Param Authorization header string true "Bearer {accessToken}"
// @Success 200 {object} service.BlockListResponse
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
// @Router /api/yggdrasil/minecraftservices/privacy/blocklist [get]
func (h *YggdrasilFriendsHandler) GetBlocklist(c *gin.Context) {
uuid, ok := h.extractProfileUUID(c)
if !ok {
return
}
resp, err := h.container.FriendsService.GetBlocklist(c.Request.Context(), uuid)
if err != nil {
h.respondFriendsError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}