Files
backend/internal/service/yggdrasil_friends_service.go
lan f2b02682c2
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled
feat(yggdrasil): 实现好友系统与 profiles 查询接口
按 MinecraftServices 文档实现好友系四组接口与 profiles 服务:

好友系统(/api/yggdrasil/minecraftservices/*)
- Friends(1.3):列表查询、ADD/REMOVE 操作(发请求/接受/拒绝/撤回/删除)
  - 状态枚举+单向记录模型,接受请求时合并两方向避免好友列表重复
- Player Attributes(1.2):friendsPreferences / 脏词过滤 / 聊天偏好读写
  - Upsert 用 map 显式写字段规避 gorm 对带 default 零值布尔字段的忽略
- Presence(1.1):Redis key+TTL 上报与好友在线状态批量查询
- Blocklist(1.6):屏蔽列表查询(含 120s Redis 缓存),Block/Unblock 内部方法

Profiles 服务
- getManyByName(2.1):POST /api/profiles/minecraft,返回 [{id,name}]
  - toLowerCase 规范化、去重、空名过滤、maxBatch=10 超限拒绝
- getByName(2.2):GET /api/users/profiles/minecraft/:name,返回 {id,name},未找到返回 404

路由与基础设施
- 路由按官方 host 前缀一一转发
  - api.mojang.com/* -> /api/yggdrasil/api/*
  - api.minecraftservices.com/* -> /api/yggdrasil/minecraftservices/*
- 新增 Friend/PlayerAttributes 模型与 AutoMigrate 注册
- 新增 FriendsService 与 Container 装配
- 抽 extractBearerToken helper,统一 MinecraftServices 错误响应

修复
- texture_service: ToggleFavorite 在 db 为 nil 时降级非事务执行
  修复 TestTextureServiceImpl_ToggleFavorite 空指针 panic
- texture_service_test: UploadTexture 用例改用真实 SHA-256 命中 mock
  修复 4 个子用例因文件大小/Hash 不匹配的失败
- profile_repository: GetByNames/FindByName 改 LOWER() 大小写不敏感
  与客户端 toLowerCase 规范化对齐

测试
- 好友模型、ADD/REMOVE/接受、属性、屏蔽、Bearer 解析单测全绿
- profiles 查询规范化、去重、超限、大小写不敏感单测全绿
- go build ./... && go test ./... 全部通过
2026-07-09 20:44:41 +08:00

512 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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{}