feat(yggdrasil): 实现好友系统与 profiles 查询接口
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled

按 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 ./... 全部通过
This commit is contained in:
2026-07-09 20:44:41 +08:00
parent 1dc9c36a3a
commit f2b02682c2
22 changed files with 1868 additions and 74 deletions

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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) {

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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)
}
})
}
}

View File

@@ -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元数据

64
internal/model/friends.go Normal file
View File

@@ -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",
}
}

View File

@@ -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)
}
}

View File

@@ -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档案 UUID32 位无连字符十六进制)
// - name用户名保持存储的大小写
type NameAndId struct {
ID string `json:"id"`
Name string `json:"name"`
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 服务依赖

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
})
}

View File

@@ -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:<profileUUID>
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.2PUT 成功后返回最新好友列表
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 解析请求目标 UUIDprofileId 优先,否则用 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-redisMGet 通过 s.redis.Client
var _ = redis.Client{}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -42,6 +42,10 @@ func AutoMigrateWithDB(db *gorm.DB, logger *zap.Logger) error {
// Yggdrasil相关表在User之后创建因为它引用User
&model.Yggdrasil{},
// 好友系统相关表(好友关系、玩家偏好属性)
&model.Friend{},
&model.PlayerAttributes{},
// 审计日志表
&model.AuditLog{},