Files
backend/internal/service/yggdrasil_friends_service.go

512 lines
17 KiB
Go
Raw Normal View History

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