按 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 ./... 全部通过
255 lines
7.8 KiB
Go
255 lines
7.8 KiB
Go
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)
|
||
}
|
||
}
|