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

255 lines
7.8 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 (
"carrotskin/internal/container"
"carrotskin/internal/middleware"
"carrotskin/pkg/auth"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// Handlers 集中管理所有Handler
type Handlers struct {
Auth *AuthHandler
User *UserHandler
Texture *TextureHandler
Profile *ProfileHandler
Captcha *CaptchaHandler
Yggdrasil *YggdrasilHandler
Friends *YggdrasilFriendsHandler
CustomSkin *CustomSkinHandler
Admin *AdminHandler
}
// NewHandlers 创建所有Handler实例
func NewHandlers(c *container.Container) *Handlers {
return &Handlers{
Auth: NewAuthHandler(c),
User: NewUserHandler(c),
Texture: NewTextureHandler(c),
Profile: NewProfileHandler(c),
Captcha: NewCaptchaHandler(c),
Yggdrasil: NewYggdrasilHandler(c),
Friends: NewYggdrasilFriendsHandler(c),
CustomSkin: NewCustomSkinHandler(c),
Admin: NewAdminHandler(c),
}
}
// RegisterRoutesWithDI 使用依赖注入注册所有路由
func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 健康检查路由
router.GET("/health", NewHealthCheck(c.DB, c.Redis))
// Swagger文档路由
if c.Config != nil && c.Config.Server.SwaggerEnabled {
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
// 创建Handler实例
h := NewHandlers(c)
// API路由组
apiGroup := router.Group("/api")
v1 := apiGroup.Group("/v1")
{
// 认证路由无需JWT
registerAuthRoutes(v1, h.Auth)
// 用户路由需要JWT认证
registerUserRoutes(v1, h.User, c.JWT)
// 材质路由
registerTextureRoutes(v1, h.Texture, c.JWT)
// 档案路由
registerProfileRoutesWithDI(v1, h.Profile, c.JWT)
// 验证码路由
registerCaptchaRoutesWithDI(v1, h.Captcha)
// CustomSkinAPI 路由
registerCustomSkinRoutes(v1, h.CustomSkin)
// 管理员路由(需要管理员权限)
registerAdminRoutes(v1, c, h.Admin)
}
// Yggdrasil API路由组独立于v1路径为 /api/yggdrasil
registerYggdrasilRoutesWithDI(apiGroup, h)
}
// registerAuthRoutes 注册认证路由
func registerAuthRoutes(v1 *gin.RouterGroup, h *AuthHandler) {
authGroup := v1.Group("/auth")
{
authGroup.POST("/register", h.Register)
authGroup.POST("/login", h.Login)
authGroup.POST("/send-code", h.SendVerificationCode)
authGroup.POST("/reset-password", h.ResetPassword)
}
}
// registerUserRoutes 注册用户路由
func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JWTService) {
// 公开用户信息路由(无需认证)
v1.GET("/users/public", h.GetPublicInfo)
// 需要认证的用户路由
userGroup := v1.Group("/user")
userGroup.Use(middleware.AuthMiddleware(jwtService))
{
userGroup.GET("/profile", h.GetProfile)
userGroup.PUT("/profile", h.UpdateProfile)
// 头像相关
userGroup.POST("/avatar/upload", h.UploadAvatar) // 直接上传头像文件
userGroup.PUT("/avatar", h.UpdateAvatar) // 更新头像URL外部URL
// 更换邮箱
userGroup.POST("/change-email", h.ChangeEmail)
// Yggdrasil密码相关
userGroup.POST("/yggdrasil-password/reset", h.ResetYggdrasilPassword)
}
}
// registerTextureRoutes 注册材质路由
func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *auth.JWTService) {
textureGroup := v1.Group("/texture")
{
// 公开路由(无需认证)
textureGroup.GET("", h.Search)
textureGroup.GET("/:id", h.Get)
// 需要认证的路由
textureAuth := textureGroup.Group("")
textureAuth.Use(middleware.AuthMiddleware(jwtService))
{
textureAuth.POST("/upload", h.Upload) // 直接上传文件
textureAuth.PUT("/:id", h.Update)
textureAuth.DELETE("/:id", h.Delete)
textureAuth.POST("/:id/favorite", h.ToggleFavorite)
textureAuth.GET("/my", h.GetUserTextures)
textureAuth.GET("/favorites", h.GetUserFavorites)
}
}
}
// registerProfileRoutesWithDI 注册档案路由(依赖注入版本)
func registerProfileRoutesWithDI(v1 *gin.RouterGroup, h *ProfileHandler, jwtService *auth.JWTService) {
profileGroup := v1.Group("/profile")
{
// 公开路由(无需认证)
profileGroup.GET("/:uuid", h.Get)
// 需要认证的路由
profileAuth := profileGroup.Group("")
profileAuth.Use(middleware.AuthMiddleware(jwtService))
{
// 同时支持 /api/v1/profile 和 /api/v1/profile/ 两种形式返回列表与创建
profileAuth.GET("", h.List)
profileAuth.POST("", h.Create)
profileAuth.POST("/", h.Create)
profileAuth.GET("/", h.List)
profileAuth.PUT("/:uuid", h.Update)
profileAuth.DELETE("/:uuid", h.Delete)
}
}
}
// registerCaptchaRoutesWithDI 注册验证码路由(依赖注入版本)
func registerCaptchaRoutesWithDI(v1 *gin.RouterGroup, h *CaptchaHandler) {
captchaGroup := v1.Group("/captcha")
{
captchaGroup.GET("/generate", h.Generate)
captchaGroup.POST("/verify", h.Verify)
}
}
// registerYggdrasilRoutesWithDI 注册Yggdrasil API路由依赖注入版本
func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *Handlers) {
ygg := v1.Group("/yggdrasil")
{
ygg.GET("", h.Yggdrasil.GetMetaData)
ygg.POST("/minecraftservices/player/certificates", h.Yggdrasil.GetPlayerCertificates)
// MinecraftServices 好友系接口(文档 1.1~1.3、1.6
mcServices := ygg.Group("/minecraftservices")
{
mcServices.GET("/friends", h.Friends.GetFriends)
mcServices.PUT("/friends", h.Friends.UpdateFriends)
mcServices.GET("/player/attributes", h.Friends.GetAttributes)
mcServices.POST("/player/attributes", h.Friends.UpdateAttributes)
mcServices.POST("/presence", h.Friends.UpdatePresence)
mcServices.GET("/privacy/blocklist", h.Friends.GetBlocklist)
}
authserver := ygg.Group("/authserver")
{
authserver.POST("/authenticate", h.Yggdrasil.Authenticate)
authserver.POST("/validate", h.Yggdrasil.ValidToken)
authserver.POST("/refresh", h.Yggdrasil.RefreshToken)
authserver.POST("/invalidate", h.Yggdrasil.InvalidToken)
authserver.POST("/signout", h.Yggdrasil.SignOut)
}
sessionServer := ygg.Group("/sessionserver")
{
sessionServer.GET("/session/minecraft/profile/:uuid", h.Yggdrasil.GetProfileByUUID)
sessionServer.POST("/session/minecraft/join", h.Yggdrasil.JoinServer)
sessionServer.GET("/session/minecraft/hasJoined", h.Yggdrasil.HasJoinedServer)
}
api := ygg.Group("/api")
{
// 对应官方 api.mojang.com/profiles/minecraft文档 2.1:批量按用户名查档)
api.POST("/profiles/minecraft", h.Yggdrasil.GetProfilesByName)
// 对应官方 api.mojang.com/users/profiles/minecraft/{name}(文档 2.2:单个用户名查档)
api.GET("/users/profiles/minecraft/:name", h.Yggdrasil.GetProfileByName)
}
}
}
// registerAdminRoutes 注册管理员路由
func registerAdminRoutes(v1 *gin.RouterGroup, c *container.Container, h *AdminHandler) {
admin := v1.Group("/admin")
admin.Use(middleware.AuthMiddleware(c.JWT))
admin.Use(middleware.RequireAdmin())
{
// 用户管理
admin.GET("/users", h.GetUserList)
admin.GET("/users/:id", h.GetUserDetail)
admin.PUT("/users/role", h.SetUserRole)
admin.PUT("/users/status", h.SetUserStatus)
// 材质管理(审核)
admin.GET("/textures", h.GetTextureList)
admin.DELETE("/textures/:id", h.DeleteTexture)
// 权限管理
admin.GET("/permissions", h.GetPermissions)
}
}
// registerCustomSkinRoutes 注册CustomSkinAPI路由
// CustomSkinAPI 协议要求根地址必须以 / 结尾
// 路由格式:
// - {ROOT}/{USERNAME}.json - 获取玩家信息
// - {ROOT}/textures/{hash} - 获取资源文件
//
// 根路径为 /api/v1/csl/
func registerCustomSkinRoutes(v1 *gin.RouterGroup, h *CustomSkinHandler) {
// CustomSkinAPI 路由组
csl := v1.Group("/csl")
{
// 获取玩家信息: {ROOT}/{USERNAME}.json
csl.GET("/:username", h.GetPlayerInfo)
// 获取资源文件: {ROOT}/textures/{hash}
csl.GET("/textures/:hash", h.GetTexture)
}
}