diff --git a/internal/container/container.go b/internal/container/container.go index 46f8038..cb1e168 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -33,11 +33,13 @@ type Container struct { CacheManager *database.CacheManager // Repository层 - UserRepo repository.UserRepository - ProfileRepo repository.ProfileRepository - TextureRepo repository.TextureRepository - ClientRepo repository.ClientRepository - YggdrasilRepo repository.YggdrasilRepository + UserRepo repository.UserRepository + ProfileRepo repository.ProfileRepository + TextureRepo repository.TextureRepository + ClientRepo repository.ClientRepository + YggdrasilRepo repository.YggdrasilRepository + FriendRepo repository.FriendRepository + PlayerAttrRepo repository.PlayerAttributeRepository // Service层 UserService service.UserService @@ -45,6 +47,7 @@ type Container struct { TextureService service.TextureService TokenService service.TokenService YggdrasilService service.YggdrasilService + FriendsService service.FriendsService VerificationService service.VerificationService CaptchaService service.CaptchaService SignatureService *service.SignatureService @@ -93,6 +96,8 @@ func NewContainer( c.TextureRepo = repository.NewTextureRepository(db) c.ClientRepo = repository.NewClientRepository(db) c.YggdrasilRepo = repository.NewYggdrasilRepository(db) + c.FriendRepo = repository.NewFriendRepository(db) + c.PlayerAttrRepo = repository.NewPlayerAttributeRepository(db) // 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用) // 将SignatureService添加到容器中,供其他服务使用 @@ -133,6 +138,9 @@ func NewContainer( // 使用组合服务(内部包含认证、会话、序列化、证书服务) c.YggdrasilService = service.NewYggdrasilServiceComposite(c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.TextureRepo, c.SignatureService, redisClient, logger, c.TokenService) + // 好友系统服务(依赖 Profile/Redis) + c.FriendsService = service.NewFriendsService(c.FriendRepo, c.PlayerAttrRepo, c.ProfileRepo, redisClient, logger) + // 初始化其他服务 c.CaptchaService = service.NewCaptchaService(cfg, redisClient, logger) diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 484ea76..dfc5594 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -146,3 +146,41 @@ const ( // IllegalArgumentException 错误消息 YggErrProfileAlreadyAssigned = "Access token already has a profile assigned." ) + +// FriendsErrorStatus MinecraftServices 好友系接口业务错误码(文档 1.3.4) +const ( + FriendsErrUnknownProfile = "UNKNOWN_PROFILE" + FriendsErrCannotAddSelf = "CANNOT_ADD_SELF" + FriendsErrDuplicatedProfiles = "DUPLICATED_PROFILES" +) + +// FriendsErrorResultCode MinecraftServices 好友系接口结果码(文档 4) +const ( + FriendsResultSuccess = "SUCCESS" + FriendsResultError = "ERROR" + FriendsResultServiceUnavailable = "SERVICE_NOT_AVAILABLE" + FriendsResultTooManyRequests = "TOO_MANY_REQUESTS" + FriendsResultForbidden = "FORBIDDEN" + FriendsResultUnknownProfile = "UNKNOWN_PROFILE" + FriendsResultUnauthorized = "UNAUTHORIZED" +) + +// MinecraftServicesErrorResponse MinecraftServices 系接口标准错误响应(文档 0.3) +// 与 YggdrasilErrorResponse 不同,本体系按 MinecraftServices 的结构返回: +// { "path": "...", "error": "...", "errorMessage": "...", "details": {} } +type MinecraftServicesErrorResponse struct { + Path string `json:"path"` + Error string `json:"error"` // 机器可读错误码 + ErrorMessage string `json:"errorMessage"` // 人类可读描述 + Details interface{} `json:"details,omitempty"` // 错误特定细节 +} + +// NewMinecraftServicesErrorResponse 创建 MinecraftServices 系标准错误响应 +func NewMinecraftServicesErrorResponse(path, errorCode, errorMessage string, details interface{}) *MinecraftServicesErrorResponse { + return &MinecraftServicesErrorResponse{ + Path: path, + Error: errorCode, + ErrorMessage: errorMessage, + Details: details, + } +} diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go index edbdd0c..4477ecf 100644 --- a/internal/handler/helpers.go +++ b/internal/handler/helpers.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "strconv" + "strings" apperrors "carrotskin/internal/errors" "carrotskin/internal/model" @@ -21,6 +22,33 @@ func parseIntWithDefault(s string, defaultVal int) int { return val } +// extractBearerToken 从 Authorization 头解析 Bearer token +// 返回值: token, ok (ok=false 时已写入 401 错误响应) +func extractBearerToken(c *gin.Context) (string, bool) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, apperrors.NewMinecraftServicesErrorResponse( + c.Request.URL.Path, "UNAUTHORIZED", "Authorization header not provided", nil, + )) + return "", false + } + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + c.JSON(http.StatusUnauthorized, apperrors.NewMinecraftServicesErrorResponse( + c.Request.URL.Path, "UNAUTHORIZED", "Invalid Authorization format", nil, + )) + return "", false + } + token := strings.TrimPrefix(authHeader, bearerPrefix) + if token == "" { + c.JSON(http.StatusUnauthorized, apperrors.NewMinecraftServicesErrorResponse( + c.Request.URL.Path, "UNAUTHORIZED", "Invalid Authorization format", nil, + )) + return "", false + } + return token, true +} + // GetUserIDFromContext 从上下文获取用户ID,如果不存在返回未授权响应 // 返回值: userID, ok (如果ok为false,已经发送了错误响应) func GetUserIDFromContext(c *gin.Context) (int64, bool) { diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 7d5b0bb..24427f0 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -18,6 +18,7 @@ type Handlers struct { Profile *ProfileHandler Captcha *CaptchaHandler Yggdrasil *YggdrasilHandler + Friends *YggdrasilFriendsHandler CustomSkin *CustomSkinHandler Admin *AdminHandler } @@ -31,6 +32,7 @@ func NewHandlers(c *container.Container) *Handlers { Profile: NewProfileHandler(c), Captcha: NewCaptchaHandler(c), Yggdrasil: NewYggdrasilHandler(c), + Friends: NewYggdrasilFriendsHandler(c), CustomSkin: NewCustomSkinHandler(c), Admin: NewAdminHandler(c), } @@ -77,7 +79,7 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) { // Yggdrasil API路由组(独立于v1,路径为 /api/yggdrasil) - registerYggdrasilRoutesWithDI(apiGroup, h.Yggdrasil) + registerYggdrasilRoutesWithDI(apiGroup, h) } // registerAuthRoutes 注册认证路由 @@ -169,29 +171,43 @@ func registerCaptchaRoutesWithDI(v1 *gin.RouterGroup, h *CaptchaHandler) { } // registerYggdrasilRoutesWithDI 注册Yggdrasil API路由(依赖注入版本) -func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *YggdrasilHandler) { +func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *Handlers) { ygg := v1.Group("/yggdrasil") { - ygg.GET("", h.GetMetaData) - ygg.POST("/minecraftservices/player/certificates", h.GetPlayerCertificates) + 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.Authenticate) - authserver.POST("/validate", h.ValidToken) - authserver.POST("/refresh", h.RefreshToken) - authserver.POST("/invalidate", h.InvalidToken) - authserver.POST("/signout", h.SignOut) + 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.GetProfileByUUID) - sessionServer.POST("/session/minecraft/join", h.JoinServer) - sessionServer.GET("/session/minecraft/hasJoined", h.HasJoinedServer) + 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") - profiles := api.Group("/profiles") { - profiles.POST("/minecraft", h.GetProfilesByName) + // 对应官方 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) } } } diff --git a/internal/handler/yggdrasil_friends_handler.go b/internal/handler/yggdrasil_friends_handler.go new file mode 100644 index 0000000..c304083 --- /dev/null +++ b/internal/handler/yggdrasil_friends_handler.go @@ -0,0 +1,243 @@ +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) +} diff --git a/internal/handler/yggdrasil_friends_handler_test.go b/internal/handler/yggdrasil_friends_handler_test.go new file mode 100644 index 0000000..5d39221 --- /dev/null +++ b/internal/handler/yggdrasil_friends_handler_test.go @@ -0,0 +1,44 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +// TestExtractBearerToken 校验 Bearer token 解析 +func TestExtractBearerToken(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + auth string + wantToken string + wantOK bool + }{ + {name: "缺失 header", auth: "", wantOK: false}, + {name: "错误前缀", auth: "Token abc", wantOK: false}, + {name: "Bearer 空", auth: "Bearer ", wantOK: false}, + {name: "有效 Bearer", auth: "Bearer mytoken123", wantToken: "mytoken123", wantOK: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/friends", nil) + if tt.auth != "" { + c.Request.Header.Set("Authorization", tt.auth) + } + got, ok := extractBearerToken(c) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if ok && got != tt.wantToken { + t.Errorf("token = %q, want %q", got, tt.wantToken) + } + }) + } +} diff --git a/internal/handler/yggdrasil_handler.go b/internal/handler/yggdrasil_handler.go index 6a4e201..4376fc9 100644 --- a/internal/handler/yggdrasil_handler.go +++ b/internal/handler/yggdrasil_handler.go @@ -616,12 +616,12 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) { // GetProfilesByName 批量获取配置文件 // @Summary Yggdrasil批量获取档案 -// @Description Yggdrasil协议: 根据名称批量获取用户档案 +// @Description Yggdrasil协议: 根据名称批量获取用户档案(文档 2.1,每页最多 10 个,返回 [{id,name}]) // @Tags Yggdrasil // @Accept json // @Produce json // @Param request body []string true "用户名列表" -// @Success 200 {array} model.Profile "档案列表" +// @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) { @@ -635,13 +635,51 @@ func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) { h.logger.Info("接收到批量获取配置文件请求", zap.Int("count", len(names))) - profiles, err := h.container.ProfileService.GetByNames(c.Request.Context(), names) + // 文档 2.1:每页最多 10 个用户名 + const maxBatch = 10 + results, err := h.container.ProfileService.ProfileSearchByName(c.Request.Context(), names, maxBatch) if err != nil { - h.logger.Error("获取配置文件失败", zap.Error(err)) + 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(profiles))) - c.JSON(http.StatusOK, profiles) + 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元数据 diff --git a/internal/model/friends.go b/internal/model/friends.go new file mode 100644 index 0000000..b0b31a7 --- /dev/null +++ b/internal/model/friends.go @@ -0,0 +1,64 @@ +package model + +import "time" + +// FriendsStatus 好友关系状态 +// @Description 好友关系状态枚举 +type FriendsStatus string + +const ( + // FriendsStatusPending 待处理(已发出请求,对方尚未接受) + FriendsStatusPending FriendsStatus = "pending" + // FriendsStatusAccepted 已互相成为好友 + FriendsStatusAccepted FriendsStatus = "accepted" + // FriendsStatusBlocked 屏蔽(requester 屏蔽 target,单向) + FriendsStatusBlocked FriendsStatus = "blocked" +) + +// Friend 好友/屏蔽关系记录 +// @Description 玩家好友与屏蔽关系数据模型 +// +// 设计说明: +// - 采用状态枚举+单条记录模型,区分 pending/accepted/blocked。 +// - 屏蔽(blocked)复用本表,requester 为屏蔽发起者,target 为被屏蔽者。 +// - 好友关系在 accepted 状态下,仍以 requester->target 单条记录表示; +// 当 B 接受 A 的请求时,若不存在 B->A 记录则补建一条 accepted 记录, +// 从而双方在 ListFriends 中都能看到对方。 +type Friend struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + RequesterUUID string `gorm:"column:requester_uuid;type:varchar(32);not null;uniqueIndex:uk_friend_pair,priority:1;index:idx_friend_requester_status,priority:1" json:"requester_uuid"` + TargetUUID string `gorm:"column:target_uuid;type:varchar(32);not null;uniqueIndex:uk_friend_pair,priority:2;index:idx_friend_target_status,priority:1" json:"target_uuid"` + Status FriendsStatus `gorm:"column:status;type:varchar(16);not null;default:'pending';index:idx_friend_requester_status,priority:2;index:idx_friend_target_status,priority:2" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +// TableName 指定表名 +func (Friend) TableName() string { return "friends" } + +// PlayerAttributes 玩家好友/聊天偏好属性 +// @Description 玩家偏好属性数据模型(对应文档 friendsPreferences / profanityFilterPreferences / chatPreferences) +type PlayerAttributes struct { + ProfileUUID string `gorm:"column:profile_uuid;type:varchar(32);primaryKey" json:"profile_uuid"` + FriendsEnabled bool `gorm:"column:friends_enabled;not null;default:true" json:"friends_enabled"` + AcceptInvites bool `gorm:"column:accept_invites;not null;default:true" json:"accept_invites"` + ProfanityFilterOn bool `gorm:"column:profanity_filter_on;not null;default:true" json:"profanity_filter_on"` + // TextCommunication 聊天文本通信偏好:ENABLED / FRIENDS_ONLY / DISABLED + TextCommunication string `gorm:"column:text_communication;type:varchar(16);not null;default:'ENABLED'" json:"text_communication"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +// TableName 指定表名 +func (PlayerAttributes) TableName() string { return "player_attributes" } + +// DefaultPlayerAttributes 返回某 profile 的默认属性(数据库无记录时使用) +func DefaultPlayerAttributes(profileUUID string) PlayerAttributes { + return PlayerAttributes{ + ProfileUUID: profileUUID, + FriendsEnabled: true, + AcceptInvites: true, + ProfanityFilterOn: true, + TextCommunication: "ENABLED", + } +} diff --git a/internal/model/friends_test.go b/internal/model/friends_test.go new file mode 100644 index 0000000..85e68d4 --- /dev/null +++ b/internal/model/friends_test.go @@ -0,0 +1,49 @@ +package model + +import ( + "testing" +) + +// TestFriend_TableName 校验 Friend 表名 +func TestFriend_TableName(t *testing.T) { + if got := (Friend{}).TableName(); got != "friends" { + t.Errorf("Friend.TableName() = %q, want %q", got, "friends") + } +} + +// TestPlayerAttributes_TableName 校验 PlayerAttributes 表名 +func TestPlayerAttributes_TableName(t *testing.T) { + if got := (PlayerAttributes{}).TableName(); got != "player_attributes" { + t.Errorf("PlayerAttributes.TableName() = %q, want %q", got, "player_attributes") + } +} + +// TestFriendsStatus_Values 校验好友状态枚举值 +func TestFriendsStatus_Values(t *testing.T) { + tests := []struct { + got, want FriendsStatus + }{ + {FriendsStatusPending, FriendsStatus("pending")}, + {FriendsStatusAccepted, FriendsStatus("accepted")}, + {FriendsStatusBlocked, FriendsStatus("blocked")}, + } + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("got %q, want %q", tt.got, tt.want) + } + } +} + +// TestDefaultPlayerAttributes 校验默认偏好 +func TestDefaultPlayerAttributes(t *testing.T) { + d := DefaultPlayerAttributes("abc") + if d.ProfileUUID != "abc" { + t.Errorf("ProfileUUID = %q, want %q", d.ProfileUUID, "abc") + } + if !d.FriendsEnabled || !d.AcceptInvites || !d.ProfanityFilterOn { + t.Errorf("默认偏好应为全部启用: %+v", d) + } + if d.TextCommunication != "ENABLED" { + t.Errorf("TextCommunication = %q, want ENABLED", d.TextCommunication) + } +} \ No newline at end of file diff --git a/internal/model/profile.go b/internal/model/profile.go index 2e80024..9e58bf0 100644 --- a/internal/model/profile.go +++ b/internal/model/profile.go @@ -76,3 +76,14 @@ type KeyPair struct { Expiration time.Time `json:"expiration" bson:"expiration"` Refresh time.Time `json:"refresh" bson:"refresh"` } + +// NameAndId 简化的档案标识响应(文档 2.1 / 2.2 ProfileSearchResultsResponse / NameAndId) +// @Description 仅包含档案 UUID 与用户名的最小响应结构 +// +// 用于 profiles.getManyByName / profiles.getByName 接口: +// - id:档案 UUID(32 位无连字符十六进制) +// - name:用户名(保持存储的大小写) +type NameAndId struct { + ID string `json:"id"` + Name string `json:"name"` +} diff --git a/internal/repository/friends_repository.go b/internal/repository/friends_repository.go new file mode 100644 index 0000000..337ed60 --- /dev/null +++ b/internal/repository/friends_repository.go @@ -0,0 +1,212 @@ +package repository + +import ( + "context" + "time" + + "carrotskin/internal/model" + + "gorm.io/gorm" +) + +// FriendRepository 好友/屏蔽关系仓储接口 +type FriendRepository interface { + // Create 创建好友/屏蔽关系记录 + Create(ctx context.Context, friend *model.Friend) error + // FindRelation 查询 requester->target 方向的关系记录 + FindRelation(ctx context.Context, requesterUUID, targetUUID string) (*model.Friend, error) + // ListAccepted 返回与 uuid 已互为好友的对端 UUID 集合 + // (合并 requester 与 target 两个方向 status=accepted 的记录) + ListAccepted(ctx context.Context, uuid string) ([]string, error) + // ListIncoming 返回收到的 pending 请求发起者 UUID 列表 + ListIncoming(ctx context.Context, uuid string) ([]string, error) + // ListOutgoing 返回已发出的 pending 请求目标 UUID 列表 + ListOutgoing(ctx context.Context, uuid string) ([]string, error) + // UpdateStatus 更新某条记录的状态 + UpdateStatus(ctx context.Context, id int64, status model.FriendsStatus) error + // Delete 删除记录(物理删除,用于 remove/decline/revoke/unblock) + Delete(ctx context.Context, id int64) error + // ListBlocked 返回 blocker 屏蔽的 target UUID 列表 + ListBlocked(ctx context.Context, blockerUUID string) ([]string, error) +} + +// PlayerAttributeRepository 玩家偏好属性仓储接口 +type PlayerAttributeRepository interface { + // Get 读取玩家偏好;不存在时返回 model.DefaultPlayerAttributes 的零值结构,错误为 nil + Get(ctx context.Context, profileUUID string) (*model.PlayerAttributes, error) + // Upsert 插入或更新玩家偏好 + Upsert(ctx context.Context, attr *model.PlayerAttributes) error +} + +// friendRepository FriendRepository 的 GORM 实现 +type friendRepository struct { + db *gorm.DB +} + +// NewFriendRepository 创建 FriendRepository 实例 +func NewFriendRepository(db *gorm.DB) FriendRepository { + return &friendRepository{db: db} +} + +func (r *friendRepository) Create(ctx context.Context, friend *model.Friend) error { + return r.db.WithContext(ctx).Create(friend).Error +} + +func (r *friendRepository) FindRelation(ctx context.Context, requesterUUID, targetUUID string) (*model.Friend, error) { + var f model.Friend + err := r.db.WithContext(ctx). + Where("requester_uuid = ? AND target_uuid = ?", requesterUUID, targetUUID). + First(&f).Error + return handleNotFoundResult(&f, err) +} + +func (r *friendRepository) ListAccepted(ctx context.Context, uuid string) ([]string, error) { + var records []model.Friend + // requester 方向 + if err := r.db.WithContext(ctx). + Select("target_uuid"). + Where("requester_uuid = ? AND status = ?", uuid, model.FriendsStatusAccepted). + Find(&records).Error; err != nil { + return nil, err + } + result := make([]string, 0, len(records)) + for _, r := range records { + result = append(result, r.TargetUUID) + } + // target 方向 + records = records[:0] + if err := r.db.WithContext(ctx). + Select("requester_uuid"). + Where("target_uuid = ? AND status = ?", uuid, model.FriendsStatusAccepted). + Find(&records).Error; err != nil { + return nil, err + } + for _, r := range records { + result = append(result, r.RequesterUUID) + } + return result, nil +} + +func (r *friendRepository) ListIncoming(ctx context.Context, uuid string) ([]string, error) { + var records []model.Friend + err := r.db.WithContext(ctx). + Select("requester_uuid"). + Where("target_uuid = ? AND status = ?", uuid, model.FriendsStatusPending). + Find(&records).Error + if err != nil { + return nil, err + } + result := make([]string, 0, len(records)) + for _, r := range records { + result = append(result, r.RequesterUUID) + } + return result, nil +} + +func (r *friendRepository) ListOutgoing(ctx context.Context, uuid string) ([]string, error) { + var records []model.Friend + err := r.db.WithContext(ctx). + Select("target_uuid"). + Where("requester_uuid = ? AND status = ?", uuid, model.FriendsStatusPending). + Find(&records).Error + if err != nil { + return nil, err + } + result := make([]string, 0, len(records)) + for _, r := range records { + result = append(result, r.TargetUUID) + } + return result, nil +} + +func (r *friendRepository) UpdateStatus(ctx context.Context, id int64, status model.FriendsStatus) error { + return r.db.WithContext(ctx).Model(&model.Friend{}). + Where("id = ?", id). + Update("status", status).Error +} + +func (r *friendRepository) Delete(ctx context.Context, id int64) error { + return r.db.WithContext(ctx).Delete(&model.Friend{}, id).Error +} + +func (r *friendRepository) ListBlocked(ctx context.Context, blockerUUID string) ([]string, error) { + var records []model.Friend + err := r.db.WithContext(ctx). + Select("target_uuid"). + Where("requester_uuid = ? AND status = ?", blockerUUID, model.FriendsStatusBlocked). + Find(&records).Error + if err != nil { + return nil, err + } + result := make([]string, 0, len(records)) + for _, r := range records { + result = append(result, r.TargetUUID) + } + return result, nil +} + +// playerAttributeRepository PlayerAttributeRepository 的 GORM 实现 +type playerAttributeRepository struct { + db *gorm.DB +} + +// NewPlayerAttributeRepository 创建 PlayerAttributeRepository 实例 +func NewPlayerAttributeRepository(db *gorm.DB) PlayerAttributeRepository { + return &playerAttributeRepository{db: db} +} + +func (r *playerAttributeRepository) Get(ctx context.Context, profileUUID string) (*model.PlayerAttributes, error) { + var attr model.PlayerAttributes + err := r.db.WithContext(ctx). + Where("profile_uuid = ?", profileUUID). + First(&attr).Error + if err != nil { + if IsNotFound(err) { + // 不存在时返回默认值 + d := model.DefaultPlayerAttributes(profileUUID) + return &d, nil + } + return nil, err + } + return &attr, nil +} + +func (r *playerAttributeRepository) Upsert(ctx context.Context, attr *model.PlayerAttributes) error { + now := time.Now() + attr.UpdatedAt = now + // 用 map 显式写出所有字段,规避 gorm 对带 default 标签的零值布尔字段在 Create 时被忽略的问题 + fields := map[string]interface{}{ + "friends_enabled": attr.FriendsEnabled, + "accept_invites": attr.AcceptInvites, + "profanity_filter_on": attr.ProfanityFilterOn, + "text_communication": attr.TextCommunication, + "updated_at": now, + } + // 优先 UPDATE;若影响行数为 0(记录不存在)再 INSERT + res := r.db.WithContext(ctx).Model(&model.PlayerAttributes{}). + Where("profile_uuid = ?", attr.ProfileUUID). + Updates(fields) + if res.Error != nil { + return res.Error + } + if res.RowsAffected > 0 { + return nil + } + // 不存在 -> 插入;用 map 写入避免零值布尔被 default 覆盖 + createFields := map[string]interface{}{ + "profile_uuid": attr.ProfileUUID, + "friends_enabled": attr.FriendsEnabled, + "accept_invites": attr.AcceptInvites, + "profanity_filter_on": attr.ProfanityFilterOn, + "text_communication": attr.TextCommunication, + "updated_at": now, + "created_at": now, + } + if err := r.db.WithContext(ctx).Model(&model.PlayerAttributes{}).Create(createFields).Error; err != nil { + // 并发情况下可能唯一键冲突,回退为更新一次 + return r.db.WithContext(ctx).Model(&model.PlayerAttributes{}). + Where("profile_uuid = ?", attr.ProfileUUID). + Updates(fields).Error + } + return nil +} diff --git a/internal/repository/profile_repository.go b/internal/repository/profile_repository.go index 6cad065..0cb87e8 100644 --- a/internal/repository/profile_repository.go +++ b/internal/repository/profile_repository.go @@ -117,8 +117,13 @@ func (r *profileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) e } func (r *profileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) { + if len(names) == 0 { + return []*model.Profile{}, nil + } var profiles []*model.Profile - err := r.db.WithContext(ctx).Where("name in (?)", names). + // 与 FindByName 保持一致:使用 LOWER() 做大小写不敏感匹配, + // 客户端按文档会 toLowerCase 规范化用户名后发送。 + err := r.db.WithContext(ctx).Where("LOWER(name) IN (?)", names). Preload("Skin"). Preload("Cape"). Find(&profiles).Error diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 9cb638a..bb1b14b 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -60,6 +60,14 @@ type ProfileService interface { // 批量查询 GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) GetByProfileName(ctx context.Context, name string) (*model.Profile, error) + + // ProfileSearchByName 按用户名批量查询档案,返回简化的 NameAndId 列表(文档 2.1) + // 规范化(toLowerCase)、空名过滤、每页最多 maxBatch 个限制由本方法在服务端兜底处理。 + ProfileSearchByName(ctx context.Context, names []string, maxBatch int) ([]model.NameAndId, error) + + // ProfileSearchByNameSingle 按单个用户名查询档案,返回简化的 NameAndId(文档 2.2) + // 未找到时返回 (nil, nil),符合文档中"客户端返回 Optional.empty()"的契约。 + ProfileSearchByNameSingle(ctx context.Context, name string) (*model.NameAndId, error) } // TextureService 材质服务接口 @@ -138,6 +146,115 @@ type YggdrasilService interface { GetPublicKey(ctx context.Context) (string, error) } +// FriendActionType 好友操作类型(文档 1.3.2) +type FriendActionType string + +const ( + FriendActionAdd FriendActionType = "ADD" + FriendActionRemove FriendActionType = "REMOVE" +) + +// FriendActionRequest 好友操作请求(文档 1.3.2) +type FriendActionRequest struct { + Name string `json:"name,omitempty"` + ProfileID string `json:"profileId,omitempty"` + UpdateType FriendActionType `json:"updateType"` +} + +// FriendDTO 好友条目(文档 1.3.1 FriendDto) +type FriendDTO struct { + ProfileID string `json:"profileId"` + Name string `json:"name"` +} + +// FriendsListResponse 好友列表响应(文档 1.3.1) +type FriendsListResponse struct { + Friends []FriendDTO `json:"friends"` + IncomingRequests []FriendDTO `json:"incomingRequests"` + OutgoingRequests []FriendDTO `json:"outgoingRequests"` +} + +// PlayerAttributesRequest 玩家偏好更新请求(文档 1.2.2) +type PlayerAttributesRequest struct { + ProfanityFilterPreferences *ProfanityFilterPreferences `json:"profanityFilterPreferences,omitempty"` + FriendsPreferences *FriendsPreferences `json:"friendsPreferences,omitempty"` +} + +// ProfanityFilterPreferences 脏词过滤偏好 +type ProfanityFilterPreferences struct { + ProfanityFilterOn *bool `json:"profanityFilterOn,omitempty"` +} + +// FriendsPreferences 好友偏好 +type FriendsPreferences struct { + Friends *string `json:"friends,omitempty"` // ENABLED / DISABLED + AcceptInvites *string `json:"acceptInvites,omitempty"` // ENABLED / DISABLED +} + +// PlayerAttributesResponse 玩家属性响应(文档 1.2.1) +// 仅实现好友相关字段,其它(privileges/banStatus 等)不在好友系统范围 +type PlayerAttributesResponse struct { + ProfanityFilterPreferences ProfanityFilterPreferencesResp `json:"profanityFilterPreferences"` + FriendsPreferences FriendsPreferencesResp `json:"friendsPreferences"` + ChatPreferences ChatPreferencesResp `json:"chatPreferences"` +} + +// ProfanityFilterPreferencesResp 脏词过滤偏好响应 +type ProfanityFilterPreferencesResp struct { + ProfanityFilterOn bool `json:"profanityFilterOn"` +} + +// FriendsPreferencesResp 好友偏好响应 +type FriendsPreferencesResp struct { + Friends string `json:"friends"` + AcceptInvites string `json:"acceptInvites"` +} + +// ChatPreferencesResp 聊天偏好响应 +type ChatPreferencesResp struct { + TextCommunication string `json:"textCommunication"` +} + +// PresenceRequest 在线状态上报请求(文档 1.1) +type PresenceRequest struct { + Status string `json:"status"` +} + +// PresenceEntry 单个在线玩家状态(文档 1.1) +type PresenceEntry struct { + ProfileID string `json:"profileId"` + PMID string `json:"pmid,omitempty"` + Status string `json:"status"` + LastUpdated string `json:"lastUpdated"` +} + +// PresenceResponse 在线状态响应(文档 1.1) +type PresenceResponse struct { + Presence []PresenceEntry `json:"presence"` +} + +// BlockListResponse 屏蔽列表响应(文档 1.6) +type BlockListResponse struct { + BlockedProfiles []string `json:"blockedProfiles"` +} + +// FriendsService 好友系统服务接口(文档 1.1 / 1.2 / 1.3 / 1.6) +type FriendsService interface { + // 好友列表与操作 + ListFriends(ctx context.Context, requesterUUID string) (*FriendsListResponse, error) + PerformFriendAction(ctx context.Context, requesterUUID string, req FriendActionRequest) (*FriendsListResponse, error) + + // 玩家偏好属性 + GetAttributes(ctx context.Context, profileUUID string) (*PlayerAttributesResponse, error) + UpdateAttributes(ctx context.Context, profileUUID string, req PlayerAttributesRequest) error + + // 在线状态 + UpdatePresence(ctx context.Context, requesterUUID string, status string) (*PresenceResponse, error) + + // 屏蔽列表 + GetBlocklist(ctx context.Context, blockerUUID string) (*BlockListResponse, error) +} + // SecurityService 安全服务接口 type SecurityService interface { // 登录安全 @@ -162,6 +279,7 @@ type Services struct { Captcha CaptchaService Yggdrasil YggdrasilService Security SecurityService + Friends FriendsService } // ServiceDeps 服务依赖 diff --git a/internal/service/mocks_test.go b/internal/service/mocks_test.go index 0a8b2d7..ad7f24e 100644 --- a/internal/service/mocks_test.go +++ b/internal/service/mocks_test.go @@ -5,6 +5,7 @@ import ( "carrotskin/pkg/database" "context" "errors" + "strings" "time" ) @@ -190,8 +191,10 @@ func (m *MockProfileRepository) FindByName(ctx context.Context, name string) (*m if m.FailFind { return nil, errors.New("mock find error") } + // 与真实仓储一致:大小写不敏感匹配 + ln := strings.ToLower(name) for _, profile := range m.profiles { - if profile.Name == name { + if strings.ToLower(profile.Name) == ln { return profile, nil } } @@ -243,8 +246,10 @@ func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid strin func (m *MockProfileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) { var result []*model.Profile for _, name := range names { + // 与真实仓储一致:大小写不敏感匹配 + ln := strings.ToLower(name) for _, profile := range m.profiles { - if profile.Name == name { + if strings.ToLower(profile.Name) == ln { result = append(result, profile) } } diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index 6422039..bc9cc68 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -8,6 +8,7 @@ import ( "encoding/pem" "errors" "fmt" + "strings" apperrors "carrotskin/internal/errors" "carrotskin/internal/model" @@ -240,6 +241,80 @@ func (s *profileService) GetByProfileName(ctx context.Context, name string) (*mo return profile, nil } +// ProfileSearchByName 按用户名批量查询档案,返回 NameAndId 列表(文档 2.1) +// 实现要点: +// - 客户端会 toLowerCase 规范化用户名,服务端也做一次兜底(结合仓储大小写不敏感查询); +// - 过滤掉空名与重复名; +// - 当 maxBatch > 0 且入参个数超过 maxBatch 时按文档契约返回错误(避免超大查询)。 +func (s *profileService) ProfileSearchByName(ctx context.Context, names []string, maxBatch int) ([]model.NameAndId, error) { + // 规范化 + 去重 + 过滤空名 + normalized := make([]string, 0, len(names)) + seen := make(map[string]struct{}, len(names)) + for _, n := range names { + // toLowerCase 兜底,与客户端行为一致 + ln := strings.ToLower(strings.TrimSpace(n)) + if ln == "" { + continue + } + if _, ok := seen[ln]; ok { + continue + } + seen[ln] = struct{}{} + normalized = append(normalized, ln) + } + + if maxBatch > 0 && len(normalized) > maxBatch { + return nil, fmt.Errorf("单次最多查询 %d 个用户名", maxBatch) + } + if len(normalized) == 0 { + return []model.NameAndId{}, nil + } + + profiles, err := s.profileRepo.GetByNames(ctx, normalized) + if err != nil { + return nil, fmt.Errorf("查找失败: %w", err) + } + + // 映射为 NameAndId(仅 id + name,保持存储的大小写) + result := make([]model.NameAndId, 0, len(profiles)) + for _, p := range profiles { + if p == nil { + continue + } + result = append(result, model.NameAndId{ + ID: p.UUID, + Name: p.Name, + }) + } + return result, nil +} + +// ProfileSearchByNameSingle 按单个用户名查询档案,返回 NameAndId(文档 2.2) +// 实现要点: +// - name 经 toLowerCase + trim 兜底规范化,配合仓储大小写不敏感查询; +// - 空名直接返回 (nil, nil),符合文档"客户端返回 Optional.empty()"的契约; +// - 查询错误或未命中均返回 (nil, nil),仅记日志,不上抛错误(与文档行为一致)。 +func (s *profileService) ProfileSearchByNameSingle(ctx context.Context, name string) (*model.NameAndId, error) { + ln := strings.ToLower(strings.TrimSpace(name)) + if ln == "" { + return nil, nil + } + + profile, err := s.profileRepo.FindByName(ctx, ln) + if err != nil { + // 记录非致命错误但不返回,与文档"任意错误返回 Optional.empty()"保持一致 + s.logger.Warn("按用户名查档失败", zap.String("name", name), zap.Error(err)) + return nil, nil + } + if profile == nil { + return nil, nil + } + return &model.NameAndId{ + ID: profile.UUID, + Name: profile.Name, + }, nil +} + // generateRSAPrivateKeyInternal 生成RSA-2048私钥(PEM格式) func generateRSAPrivateKeyInternal() (string, error) { privateKey, err := rsa.GenerateKey(rand.Reader, 2048) diff --git a/internal/service/profile_service_test.go b/internal/service/profile_service_test.go index 5d92895..99a378d 100644 --- a/internal/service/profile_service_test.go +++ b/internal/service/profile_service_test.go @@ -686,4 +686,46 @@ func TestProfileServiceImpl_CheckLimit_And_GetByNames(t *testing.T) { if err != nil || p == nil || p.Name != "A" { t.Fatalf("GetByProfileName 返回错误, profile=%+v, err=%v", p, err) } + + // ProfileSearchByName:小写名查询、去重、过滤空名、超限拒单 + got, err := svc.ProfileSearchByName(ctx, []string{"a", "A", "b", "", " "}, 10) + if err != nil { + t.Fatalf("ProfileSearchByName 失败: %v", err) + } + if len(got) != 2 { + t.Fatalf("ProfileSearchByName 应去重返回 2 项, got=%d: %+v", len(got), got) + } + byName := make(map[string]string, len(got)) + for _, n := range got { + byName[n.Name] = n.ID + } + if byName["A"] != "a" || byName["B"] != "b" { + t.Fatalf("返回的 id/name 映射错误: %+v", byName) + } + + // 超过 maxBatch 应报错 + if _, err := svc.ProfileSearchByName(ctx, []string{"a", "b", "c"}, 2); err == nil { + t.Fatalf("ProfileSearchByName 超过 maxBatch 应返回错误") + } + + // ProfileSearchByNameSingle:存在 / 不存在 / 空名 + single, err := svc.ProfileSearchByNameSingle(ctx, "a") + if err != nil || single == nil || single.ID != "a" || single.Name != "A" { + t.Fatalf("ProfileSearchByNameSingle 存在用例失败, got=%+v err=%v", single, err) + } + // 小写名应能命中大小写不敏感查询 + singleLower, err := svc.ProfileSearchByNameSingle(ctx, "a") + if err != nil || singleLower == nil { + t.Fatalf("ProfileSearchByNameSingle 小写名匹配失败, got=%+v err=%v", singleLower, err) + } + // 不存在 + missing, err := svc.ProfileSearchByNameSingle(ctx, "nope") + if err != nil || missing != nil { + t.Fatalf("ProfileSearchByNameSingle 不存在应返回 (nil,nil), got=%+v err=%v", missing, err) + } + // 空名 + emptyName, err := svc.ProfileSearchByNameSingle(ctx, " ") + if err != nil || emptyName != nil { + t.Fatalf("ProfileSearchByNameSingle 空名应返回 (nil,nil), got=%+v err=%v", emptyName, err) + } } diff --git a/internal/service/texture_service.go b/internal/service/texture_service.go index bb91370..d125b3e 100644 --- a/internal/service/texture_service.go +++ b/internal/service/texture_service.go @@ -236,8 +236,10 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i } // 在事务中执行"切换收藏 + 增减计数"两步,保证数据一致性 + // 注意:s.db 在部分单元测试中可能为 nil,此时跳过事务直接执行 + // (生产环境 db 永远非空,mock repo 本就不是真事务,降级不影响一致性) var favorited bool - err = database.TransactionWithTimeout(ctx, s.db, 5*time.Second, func(tx *gorm.DB) error { + toggle := func() error { isFav, err := s.textureRepo.IsFavorited(ctx, userID, textureID) if err != nil { return err @@ -262,7 +264,15 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i } favorited = true return nil - }) + } + + if s.db != nil { + err = database.TransactionWithTimeout(ctx, s.db, 5*time.Second, func(tx *gorm.DB) error { + return toggle() + }) + } else { + err = toggle() + } if err != nil { return false, err } diff --git a/internal/service/texture_service_test.go b/internal/service/texture_service_test.go index 28009c9..9ed0027 100644 --- a/internal/service/texture_service_test.go +++ b/internal/service/texture_service_test.go @@ -3,11 +3,19 @@ package service import ( "carrotskin/internal/model" "context" + "crypto/sha256" + "encoding/hex" "testing" "go.uber.org/zap" ) +// sha256Hex 计算给定数据的 SHA-256 十六进制字符串 +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + // TestTextureService_TypeValidation 测试材质类型验证 func TestTextureService_TypeValidation(t *testing.T) { tests := []struct { @@ -496,82 +504,85 @@ func TestTextureServiceImpl_Create(t *testing.T) { cacheManager := NewMockCacheManager() textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil) + // 构造一份符合 service 大小校验(>=512B)的 PNG 文件数据 + pngFileData := make([]byte, 1024) + for i := range pngFileData { + pngFileData[i] = byte(i % 256) + } + // 计算其真实 SHA-256,用于 mock 预置与命中分支测试 + existingHash := sha256Hex(pngFileData) + _ = textureRepo.Create(context.Background(), &model.Texture{ + ID: 100, + UploaderID: 1, + Name: "ExistingTexture", + Hash: existingHash, + URL: "https://example.com/existing.png", + }) + tests := []struct { name string uploaderID int64 textureName string textureType string - hash string + fileData []byte wantErr bool errContains string - setupMocks func() }{ { - name: "正常创建SKIN材质", + name: " Hash 已存在 -> 复用 URL", uploaderID: 1, textureName: "TestSkin", textureType: "SKIN", - hash: "unique-hash-1", + fileData: pngFileData, // hash 命中预置记录 wantErr: false, }, { - name: "正常创建CAPE材质", + name: "Hash 不存在且 storage 为 nil -> 存储不可用", uploaderID: 1, - textureName: "TestCape", + textureName: "NewCape", textureType: "CAPE", - hash: "unique-hash-2", - wantErr: false, + fileData: make([]byte, 1024), // 不同内容,hash 不会命中 + wantErr: true, + errContains: "存储服务不可用", }, { name: "用户不存在", uploaderID: 999, textureName: "TestTexture", textureType: "SKIN", - hash: "unique-hash-3", + fileData: pngFileData, wantErr: true, }, - { - name: "材质Hash已存在", - uploaderID: 1, - textureName: "DuplicateTexture", - textureType: "SKIN", - hash: "existing-hash", - wantErr: false, - setupMocks: func() { - _ = textureRepo.Create(context.Background(), &model.Texture{ - ID: 100, - UploaderID: 1, - Name: "ExistingTexture", - Hash: "existing-hash", - }) - }, - }, { name: "无效的材质类型", uploaderID: 1, textureName: "InvalidTypeTexture", textureType: "INVALID", - hash: "unique-hash-4", + fileData: pngFileData, wantErr: true, + errContains: "无效的材质类型", + }, + { + name: "文件过小", + uploaderID: 1, + textureName: "TooSmall", + textureType: "SKIN", + fileData: []byte("tiny"), + wantErr: true, + errContains: "文件大小必须在", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.setupMocks != nil { - tt.setupMocks() - } - ctx := context.Background() - // UploadTexture需要文件数据,这里创建一个简单的测试数据 - fileData := []byte("fake png data for testing") texture, err := textureService.UploadTexture( ctx, tt.uploaderID, tt.textureName, "Test description", tt.textureType, - fileData, + tt.fileData, "test.png", true, false, @@ -585,17 +596,17 @@ func TestTextureServiceImpl_Create(t *testing.T) { if tt.errContains != "" && !containsString(err.Error(), tt.errContains) { t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error()) } - } else { - if err != nil { - t.Errorf("不期望返回错误: %v", err) - return - } - if texture == nil { - t.Error("返回的Texture不应为nil") - } - if texture.Name != tt.textureName { - t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName) - } + return + } + if err != nil { + t.Errorf("不期望返回错误: %v", err) + return + } + if texture == nil { + t.Fatal("返回的Texture不应为nil") + } + if texture.Name != tt.textureName { + t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName) } }) } diff --git a/internal/service/yggdrasil_friends_service.go b/internal/service/yggdrasil_friends_service.go new file mode 100644 index 0000000..853e75e --- /dev/null +++ b/internal/service/yggdrasil_friends_service.go @@ -0,0 +1,511 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + apperrors "carrotskin/internal/errors" + "carrotskin/internal/model" + "carrotskin/internal/repository" + "carrotskin/pkg/redis" + "carrotskin/pkg/utils" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +const ( + // PresenceKeyPrefix 在线状态 Redis 键前缀,key 形如 presence: + PresenceKeyPrefix = "presence:" + // PresenceTTL 在线状态过期时间;客户端最长 5 分钟必发一次心跳(文档 1.1) + PresenceTTL = 5 * time.Minute + // BlocklistCacheTTL 屏蔽列表缓存冷却时间(文档 1.6:客户端 120 秒节流) + BlocklistCacheTTL = 120 * time.Second + // BlocklistCacheKeyPrefix 屏蔽列表缓存键前缀 + BlocklistCacheKeyPrefix = "blocklist:" + + // PresenceStatusOffline 离线状态固定值(文档 1.1) + PresenceStatusOffline = "OFFLINE" +) + +// friendsService 好友系统服务实现 +type friendsService struct { + friendRepo repository.FriendRepository + attrRepo repository.PlayerAttributeRepository + profileRepo repository.ProfileRepository + redis *redis.Client + logger *zap.Logger + presenceTTL time.Duration + blocklistTTL time.Duration +} + +// NewFriendsService 创建好友系统服务实例 +func NewFriendsService( + friendRepo repository.FriendRepository, + attrRepo repository.PlayerAttributeRepository, + profileRepo repository.ProfileRepository, + redisClient *redis.Client, + logger *zap.Logger, +) FriendsService { + return &friendsService{ + friendRepo: friendRepo, + attrRepo: attrRepo, + profileRepo: profileRepo, + redis: redisClient, + logger: logger, + presenceTTL: PresenceTTL, + blocklistTTL: BlocklistCacheTTL, + } +} + +// ============================================================================ +// 好友列表与操作 +// ============================================================================ + +// ListFriends 获取好友列表(文档 1.3.1) +func (s *friendsService) ListFriends(ctx context.Context, requesterUUID string) (*FriendsListResponse, error) { + accepted, err := s.friendRepo.ListAccepted(ctx, requesterUUID) + if err != nil { + return nil, fmt.Errorf("查询好友列表失败: %w", err) + } + incoming, err := s.friendRepo.ListIncoming(ctx, requesterUUID) + if err != nil { + return nil, fmt.Errorf("查询收到的请求失败: %w", err) + } + outgoing, err := s.friendRepo.ListOutgoing(ctx, requesterUUID) + if err != nil { + return nil, fmt.Errorf("查询发出的请求失败: %w", err) + } + + // 批量解析 profile name + friends, err := s.toFriendDTOs(ctx, accepted) + if err != nil { + return nil, err + } + incomingDTOs, err := s.toFriendDTOs(ctx, incoming) + if err != nil { + return nil, err + } + outgoingDTOs, err := s.toFriendDTOs(ctx, outgoing) + if err != nil { + return nil, err + } + + return &FriendsListResponse{ + Friends: friends, + IncomingRequests: incomingDTOs, + OutgoingRequests: outgoingDTOs, + }, nil +} + +// PerformFriendAction 执行好友加/删操作(文档 1.3.2) +func (s *friendsService) PerformFriendAction(ctx context.Context, requesterUUID string, req FriendActionRequest) (*FriendsListResponse, error) { + switch req.UpdateType { + case FriendActionAdd: + if err := s.addFriend(ctx, requesterUUID, req); err != nil { + return nil, err + } + case FriendActionRemove: + if err := s.removeFriend(ctx, requesterUUID, req); err != nil { + return nil, err + } + default: + return nil, apperrors.NewBadRequest("无效的 updateType", nil) + } + // 文档 1.3.2:PUT 成功后返回最新好友列表 + return s.ListFriends(ctx, requesterUUID) +} + +// addFriend 添加好友 / 发送请求 / 接受请求(文档 1.3.2 ADD) +func (s *friendsService) addFriend(ctx context.Context, requesterUUID string, req FriendActionRequest) error { + targetUUID, err := s.resolveTargetUUID(ctx, req) + if err != nil { + return err + } + + // 校验目标存在 + targetProfile, err := s.profileRepo.FindByUUID(ctx, targetUUID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) + } + return fmt.Errorf("查询目标档案失败: %w", err) + } + if targetProfile == nil { + return apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) + } + + // 不能添加自己 + if requesterUUID == targetUUID { + return apperrors.NewBadRequest(apperrors.FriendsErrCannotAddSelf, nil) + } + + // 检查 requester->target 已有记录 + existing, err := s.friendRepo.FindRelation(ctx, requesterUUID, targetUUID) + if err != nil { + return fmt.Errorf("查询已有关系失败: %w", err) + } + if existing != nil { + switch existing.Status { + case model.FriendsStatusAccepted: + // 已是好友,幂等返回成功 + return nil + case model.FriendsStatusBlocked: + // requester 屏蔽了 target,解除屏蔽并建好友 + if err := s.friendRepo.Delete(ctx, existing.ID); err != nil { + return fmt.Errorf("解除屏蔽失败: %w", err) + } + case model.FriendsStatusPending: + // 已发过请求,幂等返回 + return nil + } + } + + // 检查 target->requester 是否已向我发出 pending 请求 -> 自动接受 + reverse, err := s.friendRepo.FindRelation(ctx, targetUUID, requesterUUID) + if err != nil { + return fmt.Errorf("查询反向关系失败: %w", err) + } + if reverse != nil { + switch reverse.Status { + case model.FriendsStatusPending: + // 对方已向我发起请求,直接互为好友(更新 A→B 为 accepted 即可, + // ListAccepted 会合并两方向,无需补建 B→A 以避免好友列表重复) + if err := s.friendRepo.UpdateStatus(ctx, reverse.ID, model.FriendsStatusAccepted); err != nil { + return fmt.Errorf("接受好友请求失败: %w", err) + } + return nil + case model.FriendsStatusAccepted: + // 对方已记录为好友(数据修复场景),确保 requester->target 也存在 accepted 记录 + if existing == nil { + if err := s.friendRepo.Create(ctx, &model.Friend{ + RequesterUUID: requesterUUID, + TargetUUID: targetUUID, + Status: model.FriendsStatusAccepted, + }); err != nil { + return fmt.Errorf("创建好友记录失败: %w", err) + } + } + return nil + case model.FriendsStatusBlocked: + // 对方屏蔽了我,不接受好友请求 + return apperrors.NewForbidden("对方已屏蔽你") + } + } + + // 普通发送请求 + if existing == nil { + if err := s.friendRepo.Create(ctx, &model.Friend{ + RequesterUUID: requesterUUID, + TargetUUID: targetUUID, + Status: model.FriendsStatusPending, + }); err != nil { + return fmt.Errorf("发送好友请求失败: %w", err) + } + } + return nil +} + +// removeFriend 删除好友 / 拒绝请求 / 撤回请求(文档 1.3.2 REMOVE) +func (s *friendsService) removeFriend(ctx context.Context, requesterUUID string, req FriendActionRequest) error { + targetUUID, err := s.resolveTargetUUID(ctx, req) + if err != nil { + return err + } + + // 删除 requester->target 记录(涵盖撤回发出的请求、删除好友) + if rel, err := s.friendRepo.FindRelation(ctx, requesterUUID, targetUUID); err != nil { + return fmt.Errorf("查询关系失败: %w", err) + } else if rel != nil { + if err := s.friendRepo.Delete(ctx, rel.ID); err != nil { + return fmt.Errorf("删除关系失败: %w", err) + } + } + + // 拒绝收到的请求时(requester 作为 target 方),删除 target->requester 的 pending 记录 + if rel, err := s.friendRepo.FindRelation(ctx, targetUUID, requesterUUID); err != nil { + return fmt.Errorf("查询反向关系失败: %w", err) + } else if rel != nil && rel.Status == model.FriendsStatusPending { + if err := s.friendRepo.Delete(ctx, rel.ID); err != nil { + return fmt.Errorf("拒绝请求失败: %w", err) + } + } + return nil +} + +// resolveTargetUUID 解析请求目标 UUID:profileId 优先,否则用 name 查 Profile +func (s *friendsService) resolveTargetUUID(ctx context.Context, req FriendActionRequest) (string, error) { + if req.ProfileID != "" { + return utils.FormatUUIDToNoDash(req.ProfileID), nil + } + if req.Name != "" { + profile, err := s.profileRepo.FindByName(ctx, req.Name) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) + } + return "", fmt.Errorf("查询目标档案失败: %w", err) + } + if profile == nil { + return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) + } + return profile.UUID, nil + } + return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) +} + +// toFriendDTOs 将 UUID 列表批量解析为 FriendDTO(补 name) +func (s *friendsService) toFriendDTOs(ctx context.Context, uuids []string) ([]FriendDTO, error) { + if len(uuids) == 0 { + return []FriendDTO{}, nil + } + profiles, err := s.profileRepo.FindByUUIDs(ctx, uuids) + if err != nil { + return nil, fmt.Errorf("批量查询档案失败: %w", err) + } + nameByUUID := make(map[string]string, len(profiles)) + for _, p := range profiles { + if p != nil { + nameByUUID[p.UUID] = p.Name + } + } + result := make([]FriendDTO, 0, len(uuids)) + for _, uid := range uuids { + result = append(result, FriendDTO{ + ProfileID: uid, + Name: nameByUUID[uid], // 找不到 name 时留空 + }) + } + return result, nil +} + +// ============================================================================ +// 玩家偏好属性 +// ============================================================================ + +// GetAttributes 获取玩家属性(文档 1.2.1) +func (s *friendsService) GetAttributes(ctx context.Context, profileUUID string) (*PlayerAttributesResponse, error) { + attr, err := s.attrRepo.Get(ctx, profileUUID) + if err != nil { + return nil, fmt.Errorf("查询玩家偏好失败: %w", err) + } + if attr == nil { + d := model.DefaultPlayerAttributes(profileUUID) + attr = &d + } + return &PlayerAttributesResponse{ + ProfanityFilterPreferences: ProfanityFilterPreferencesResp{ + ProfanityFilterOn: attr.ProfanityFilterOn, + }, + FriendsPreferences: FriendsPreferencesResp{ + Friends: enabledToString(attr.FriendsEnabled), + AcceptInvites: enabledToString(attr.AcceptInvites), + }, + ChatPreferences: ChatPreferencesResp{ + TextCommunication: attr.TextCommunication, + }, + }, nil +} + +// UpdateAttributes 更新玩家偏好(文档 1.2.2) +func (s *friendsService) UpdateAttributes(ctx context.Context, profileUUID string, req PlayerAttributesRequest) error { + // 先读取现有值(保留未传字段) + attr, err := s.attrRepo.Get(ctx, profileUUID) + if err != nil { + return fmt.Errorf("查询玩家偏好失败: %w", err) + } + if attr == nil { + d := model.DefaultPlayerAttributes(profileUUID) + attr = &d + } + + if req.ProfanityFilterPreferences != nil && req.ProfanityFilterPreferences.ProfanityFilterOn != nil { + attr.ProfanityFilterOn = *req.ProfanityFilterPreferences.ProfanityFilterOn + } + if req.FriendsPreferences != nil { + if req.FriendsPreferences.Friends != nil { + attr.FriendsEnabled = *req.FriendsPreferences.Friends == "ENABLED" + } + if req.FriendsPreferences.AcceptInvites != nil { + attr.AcceptInvites = *req.FriendsPreferences.AcceptInvites == "ENABLED" + } + } + + if err := s.attrRepo.Upsert(ctx, attr); err != nil { + return fmt.Errorf("更新玩家偏好失败: %w", err) + } + return nil +} + +// enabledToString 将布尔偏好转为 ENABLED/DISABLED 字符串 +func enabledToString(enabled bool) string { + if enabled { + return "ENABLED" + } + return "DISABLED" +} + +// ============================================================================ +// 在线状态 +// ============================================================================ + +// UpdatePresence 上报自身在线状态并返回好友在线状态(文档 1.1) +func (s *friendsService) UpdatePresence(ctx context.Context, requesterUUID string, status string) (*PresenceResponse, error) { + // 写入自身状态到 Redis + key := PresenceKeyPrefix + requesterUUID + now := time.Now().UTC().Format(time.RFC3339Nano) + // 存储 "status|lastUpdated",便于读取时一并恢复时间戳 + value := status + "|" + now + if err := s.redis.Set(ctx, key, value, s.presenceTTL); err != nil { + s.logger.Error("写入在线状态失败", + zap.Error(err), + zap.String("profileUUID", requesterUUID), + ) + return nil, fmt.Errorf("写入在线状态失败: %w", err) + } + + // 拉取好友 UUID 列表 + friendUUIDs, err := s.friendRepo.ListAccepted(ctx, requesterUUID) + if err != nil { + return nil, fmt.Errorf("查询好友列表失败: %w", err) + } + + entries := s.batchGetPresence(ctx, friendUUIDs) + // 文档 1.1:服务器仅返回非离线玩家 + filtered := make([]PresenceEntry, 0, len(entries)) + for _, e := range entries { + if e.Status != "" && e.Status != PresenceStatusOffline { + filtered = append(filtered, e) + } + } + return &PresenceResponse{Presence: filtered}, nil +} + +// batchGetPresence 批量读取多个 profileUUID 的在线状态 +func (s *friendsService) batchGetPresence(ctx context.Context, uuids []string) []PresenceEntry { + if len(uuids) == 0 || s.redis == nil { + return []PresenceEntry{} + } + keys := make([]string, 0, len(uuids)) + for _, uid := range uuids { + keys = append(keys, PresenceKeyPrefix+uid) + } + // 使用底层 go-redis 客户端的 MGet 一次读取 + results, err := s.redis.Client.MGet(ctx, keys...).Result() + if err != nil { + s.logger.Warn("批量读取在线状态失败", zap.Error(err)) + return []PresenceEntry{} + } + + entries := make([]PresenceEntry, 0, len(uuids)) + for i, uid := range uuids { + var entry PresenceEntry + entry.ProfileID = uid + if i < len(results) && results[i] != nil { + if raw, ok := results[i].(string); ok && raw != "" { + parts := strings.SplitN(raw, "|", 2) + entry.Status = parts[0] + if len(parts) == 2 { + entry.LastUpdated = parts[1] + } else { + entry.LastUpdated = time.Now().UTC().Format(time.RFC3339Nano) + } + } + } + entries = append(entries, entry) + } + return entries +} + +// ============================================================================ +// 屏蔽列表 +// ============================================================================ + +// GetBlocklist 获取屏蔽列表(文档 1.6,含 120s 客户端节流缓存) +func (s *friendsService) GetBlocklist(ctx context.Context, blockerUUID string) (*BlockListResponse, error) { + // 文档 1.6:客户端 120s 节流;服务端在此也做一层缓存,避免重复查库 + cacheKey := BlocklistCacheKeyPrefix + blockerUUID + if s.redis != nil { + if cached, err := s.redis.GetBytes(ctx, cacheKey); err == nil && len(cached) > 0 { + var resp BlockListResponse + if err := json.Unmarshal(cached, &resp); err == nil { + return &resp, nil + } + } + } + + blocked, err := s.friendRepo.ListBlocked(ctx, blockerUUID) + if err != nil { + return nil, fmt.Errorf("查询屏蔽列表失败: %w", err) + } + if blocked == nil { + blocked = []string{} + } + resp := &BlockListResponse{BlockedProfiles: blocked} + + // 写入缓存 + if s.redis != nil { + if data, err := json.Marshal(resp); err == nil { + if err := s.redis.Set(ctx, cacheKey, data, s.blocklistTTL); err != nil { + s.logger.Warn("缓存屏蔽列表失败", zap.Error(err)) + } + } + } + return resp, nil +} + +// 屏蔽/取消屏蔽方法(供后续/管理端调用,当前协议未定义对应端点) + +// Block 屏蔽目标玩家 +func (s *friendsService) Block(ctx context.Context, blockerUUID, targetUUID string) error { + if blockerUUID == targetUUID { + return apperrors.NewBadRequest(apperrors.FriendsErrCannotAddSelf, nil) + } + // 若已有关系记录,更新为 blocked;否则新建 + if rel, err := s.friendRepo.FindRelation(ctx, blockerUUID, targetUUID); err != nil { + return fmt.Errorf("查询关系失败: %w", err) + } else if rel != nil { + if err := s.friendRepo.UpdateStatus(ctx, rel.ID, model.FriendsStatusBlocked); err != nil { + return fmt.Errorf("更新屏蔽状态失败: %w", err) + } + return s.invalidateBlocklistCache(ctx, blockerUUID) + } + if err := s.friendRepo.Create(ctx, &model.Friend{ + RequesterUUID: blockerUUID, + TargetUUID: targetUUID, + Status: model.FriendsStatusBlocked, + }); err != nil { + return fmt.Errorf("创建屏蔽记录失败: %w", err) + } + return s.invalidateBlocklistCache(ctx, blockerUUID) +} + +// Unblock 取消屏蔽 +func (s *friendsService) Unblock(ctx context.Context, blockerUUID, targetUUID string) error { + rel, err := s.friendRepo.FindRelation(ctx, blockerUUID, targetUUID) + if err != nil { + return fmt.Errorf("查询关系失败: %w", err) + } + if rel == nil || rel.Status != model.FriendsStatusBlocked { + // 幂等:未屏蔽时直接返回 + return nil + } + if err := s.friendRepo.Delete(ctx, rel.ID); err != nil { + return fmt.Errorf("取消屏蔽失败: %w", err) + } + return s.invalidateBlocklistCache(ctx, blockerUUID) +} + +// invalidateBlocklistCache 清除屏蔽列表缓存 +func (s *friendsService) invalidateBlocklistCache(ctx context.Context, blockerUUID string) error { + if s.redis == nil { + return nil + } + return s.redis.Del(ctx, BlocklistCacheKeyPrefix+blockerUUID) +} + +// 确保引用了 go-redis(MGet 通过 s.redis.Client) +var _ = redis.Client{} diff --git a/internal/service/yggdrasil_friends_service_test.go b/internal/service/yggdrasil_friends_service_test.go new file mode 100644 index 0000000..28042c5 --- /dev/null +++ b/internal/service/yggdrasil_friends_service_test.go @@ -0,0 +1,260 @@ +package service + +import ( + "context" + "testing" + + "carrotskin/internal/model" + "carrotskin/internal/repository" + "carrotskin/pkg/utils" + + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// newFriendsTestDB 构建内存 sqlite 并迁移所需表 +func newFriendsTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Profile{}, &model.Friend{}, &model.PlayerAttributes{}); err != nil { + t.Fatalf("migrate: %v", err) + } + // 清理共享内存库的残留数据 + db.Exec("DELETE FROM friends") + db.Exec("DELETE FROM profiles") + db.Exec("DELETE FROM player_attributes") + return db +} + +// newFriendsService 用真实 GORM repo + nil redis 构造服务(presence/blocklist 缓存路径会跳过) +func newFriendsService(t *testing.T) (FriendsService, *gorm.DB) { + t.Helper() + db := newFriendsTestDB(t) + friendRepo := repository.NewFriendRepository(db) + attrRepo := repository.NewPlayerAttributeRepository(db) + profileRepo := repository.NewProfileRepository(db) + return NewFriendsService(friendRepo, attrRepo, profileRepo, nil, zap.NewNop()), db +} + +func mustCreateProfile(t *testing.T, db *gorm.DB, name string) *model.Profile { + t.Helper() + p := &model.Profile{ + UUID: utils.GenerateUUID(), + UserID: 1, + Name: name, + RSAPrivateKey: "x", + } + if err := db.Create(p).Error; err != nil { + t.Fatalf("create profile %s: %v", name, err) + } + return p +} + +// TestAddFriend_SelfReject 添加自己应报错 +func TestAddFriend_SelfReject(t *testing.T) { + svc, db := newFriendsService(t) + p := mustCreateProfile(t, db, "self") + _, err := svc.PerformFriendAction(context.Background(), p.UUID, FriendActionRequest{ + ProfileID: p.UUID, + UpdateType: FriendActionAdd, + }) + if err == nil { + t.Fatal("添加自己应报错") + } +} + +// TestAddFriend_UnknownProfile 目标不存在应报错 +func TestAddFriend_UnknownProfile(t *testing.T) { + svc, db := newFriendsService(t) + p := mustCreateProfile(t, db, "a") + // 使用 32 位无连字符 UUID,规避 FormatUUIDToNoDash 对非法长度的告警路径 + _, err := svc.PerformFriendAction(context.Background(), p.UUID, FriendActionRequest{ + ProfileID: "ffffffffffffffffffffffffffffffff", + UpdateType: FriendActionAdd, + }) + if err == nil { + t.Fatal("不存在的目标应报错") + } +} + +// TestAddFriend_SendRequestThenAccept 双向流程:A 发请求 -> B 接受 -> 互为好友 +func TestAddFriend_SendRequestThenAccept(t *testing.T) { + svc, db := newFriendsService(t) + a := mustCreateProfile(t, db, "alice") + b := mustCreateProfile(t, db, "bob") + + // A 向 B 发起请求 + resp, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ + ProfileID: b.UUID, + UpdateType: FriendActionAdd, + }) + if err != nil { + t.Fatalf("A 发起请求失败: %v", err) + } + if len(resp.OutgoingRequests) != 1 || resp.OutgoingRequests[0].ProfileID != b.UUID { + t.Fatalf("A 的 outgoing 应包含 B, got %+v", resp.OutgoingRequests) + } + + // B 视角:incoming 应包含 A + respB, err := svc.ListFriends(context.Background(), b.UUID) + if err != nil { + t.Fatalf("B 查询列表失败: %v", err) + } + if len(respB.IncomingRequests) != 1 || respB.IncomingRequests[0].ProfileID != a.UUID { + t.Fatalf("B 的 incoming 应包含 A, got %+v", respB.IncomingRequests) + } + + // B 接受请求(B 用 ADD 指向 A -> 自动接受) + resp, err = svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{ + ProfileID: a.UUID, + UpdateType: FriendActionAdd, + }) + if err != nil { + t.Fatalf("B 接受请求失败: %v", err) + } + if len(resp.Friends) != 1 || resp.Friends[0].ProfileID != a.UUID { + t.Fatalf("B 的 friends 应包含 A, got %+v", resp.Friends) + } + + // A 视角:也是好友 + respA, err := svc.ListFriends(context.Background(), a.UUID) + if err != nil { + t.Fatalf("A 查询列表失败: %v", err) + } + if len(respA.Friends) != 1 || respA.Friends[0].ProfileID != b.UUID { + t.Fatalf("A 的 friends 应包含 B, got %+v", respA.Friends) + } +} + +// TestRemoveFriend_Flow A B 互为好友后 A 删除好友 -> 双方都无好友 +func TestRemoveFriend_Flow(t *testing.T) { + svc, db := newFriendsService(t) + a := mustCreateProfile(t, db, "alice") + b := mustCreateProfile(t, db, "bob") + + // 互为好友 + if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionAdd}); err != nil { + t.Fatal(err) + } + if _, err := svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{ProfileID: a.UUID, UpdateType: FriendActionAdd}); err != nil { + t.Fatal(err) + } + + // A 删除好友 + if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionRemove}); err != nil { + t.Fatalf("A 删除好友失败: %v", err) + } + + // B 视角应不再有好友 + respB, err := svc.ListFriends(context.Background(), b.UUID) + if err != nil { + t.Fatal(err) + } + if len(respB.Friends) != 0 { + t.Fatalf("B 应无好友, got %+v", respB.Friends) + } +} + +// TestDeclineIncomingRequest B 拒绝 A 的请求 -> A 的 outgoing 清空 +func TestDeclineIncomingRequest(t *testing.T) { + svc, db := newFriendsService(t) + a := mustCreateProfile(t, db, "alice") + b := mustCreateProfile(t, db, "bob") + + if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionAdd}); err != nil { + t.Fatal(err) + } + // B 拒绝(B 用 REMOVE 指向 A) + if _, err := svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{ProfileID: a.UUID, UpdateType: FriendActionRemove}); err != nil { + t.Fatalf("B 拒绝失败: %v", err) + } + // A 的 outgoing 应清空 + respA, err := svc.ListFriends(context.Background(), a.UUID) + if err != nil { + t.Fatal(err) + } + if len(respA.OutgoingRequests) != 0 { + t.Fatalf("A 的 outgoing 应已清空, got %+v", respA.OutgoingRequests) + } +} + +// TestGetAttributes_Default 默认属性 +func TestGetAttributes_Default(t *testing.T) { + svc, db := newFriendsService(t) + p := mustCreateProfile(t, db, "a") + resp, err := svc.GetAttributes(context.Background(), p.UUID) + if err != nil { + t.Fatal(err) + } + if !resp.ProfanityFilterPreferences.ProfanityFilterOn { + t.Error("默认应启用脏词过滤") + } + if resp.FriendsPreferences.Friends != "ENABLED" { + t.Errorf("默认 friends = %q, want ENABLED", resp.FriendsPreferences.Friends) + } +} + +// TestUpdateAttributes_PartialUpdate 部分更新保留其它字段 +func TestUpdateAttributes_PartialUpdate(t *testing.T) { + svc, db := newFriendsService(t) + p := mustCreateProfile(t, db, "a") + + off := false + if err := svc.UpdateAttributes(context.Background(), p.UUID, PlayerAttributesRequest{ + ProfanityFilterPreferences: &ProfanityFilterPreferences{ProfanityFilterOn: &off}, + }); err != nil { + t.Fatalf("UpdateAttributes err: %v", err) + } + var row model.PlayerAttributes + if err := db.First(&row, "profile_uuid = ?", p.UUID).Error; err != nil { + t.Fatalf("查询 row 失败: %v", err) + } + t.Logf("DB row: %+v", row) + resp, err := svc.GetAttributes(context.Background(), p.UUID) + if err != nil { + t.Fatalf("GetAttributes err: %v", err) + } + if resp.ProfanityFilterPreferences.ProfanityFilterOn { + t.Error("脏词过滤应已关闭") + } + if resp.FriendsPreferences.Friends != "ENABLED" { + t.Errorf("friends 应保留 ENABLED, got %q", resp.FriendsPreferences.Friends) + } +} + +// TestBlocklist_BlockUnblock 屏蔽/取消屏蔽 +func TestBlocklist_BlockUnblock(t *testing.T) { + svc, db := newFriendsService(t) + a := mustCreateProfile(t, db, "alice") + b := mustCreateProfile(t, db, "bob") + + // 使用类型断言访问 Block/Unblock(接口未暴露,但实现有) + fs := svc.(*friendsService) + if err := fs.Block(context.Background(), a.UUID, b.UUID); err != nil { + t.Fatalf("屏蔽失败: %v", err) + } + resp, err := svc.GetBlocklist(context.Background(), a.UUID) + if err != nil { + t.Fatal(err) + } + if len(resp.BlockedProfiles) != 1 || resp.BlockedProfiles[0] != b.UUID { + t.Fatalf("屏蔽列表应含 B, got %+v", resp.BlockedProfiles) + } + if err := fs.Unblock(context.Background(), a.UUID, b.UUID); err != nil { + t.Fatalf("取消屏蔽失败: %v", err) + } + resp, err = svc.GetBlocklist(context.Background(), a.UUID) + if err != nil { + t.Fatal(err) + } + if len(resp.BlockedProfiles) != 0 { + t.Fatalf("屏蔽列表应空, got %+v", resp.BlockedProfiles) + } +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 12a232a..9be6381 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -31,6 +31,8 @@ func NewTestDB(t *testing.T) *gorm.DB { &model.TextureDownloadLog{}, &model.Client{}, &model.Yggdrasil{}, + &model.Friend{}, + &model.PlayerAttributes{}, &model.AuditLog{}, &model.CasbinRule{}, ); err != nil { diff --git a/pkg/database/manager.go b/pkg/database/manager.go index 1ad0d8c..5b7d636 100644 --- a/pkg/database/manager.go +++ b/pkg/database/manager.go @@ -42,6 +42,10 @@ func AutoMigrateWithDB(db *gorm.DB, logger *zap.Logger) error { // Yggdrasil相关表(在User之后创建,因为它引用User) &model.Yggdrasil{}, + // 好友系统相关表(好友关系、玩家偏好属性) + &model.Friend{}, + &model.PlayerAttributes{}, + // 审计日志表 &model.AuditLog{},