package service import ( "context" "errors" "fmt" "strings" "time" apperrors "carrotskin/internal/errors" "carrotskin/internal/model" "carrotskin/internal/repository" "carrotskin/pkg/redis" "carrotskin/pkg/utils" "go.uber.org/zap" "gorm.io/gorm" ) const ( // PresenceKeyPrefix 在线状态 Redis 键前缀,key 形如 presence: PresenceKeyPrefix = "presence:" // PresenceTTL 在线状态过期时间;客户端最长 5 分钟必发一次心跳(文档 1.1) PresenceTTL = 5 * time.Minute // BlocklistCacheTTL 屏蔽列表缓存冷却时间(文档 1.6:客户端 120 秒节流) BlocklistCacheTTL = 120 * time.Second // BlocklistCacheKeyPrefix 屏蔽列表缓存键前缀 BlocklistCacheKeyPrefix = "blocklist:" // PresenceStatusOffline 离线状态固定值(文档 1.1) PresenceStatusOffline = "OFFLINE" ) // friendsService 好友系统服务实现 type friendsService struct { friendRepo repository.FriendRepository attrRepo repository.PlayerAttributeRepository profileRepo repository.ProfileRepository redis *redis.Client logger *zap.Logger presenceTTL time.Duration blocklistTTL time.Duration } // NewFriendsService 创建好友系统服务实例 func NewFriendsService( friendRepo repository.FriendRepository, attrRepo repository.PlayerAttributeRepository, profileRepo repository.ProfileRepository, redisClient *redis.Client, logger *zap.Logger, ) FriendsService { return &friendsService{ friendRepo: friendRepo, attrRepo: attrRepo, profileRepo: profileRepo, redis: redisClient, logger: logger, presenceTTL: PresenceTTL, blocklistTTL: BlocklistCacheTTL, } } // ============================================================================ // 好友列表与操作 // ============================================================================ // ListFriends 获取好友列表(文档 1.3.1) func (s *friendsService) ListFriends(ctx context.Context, requesterUUID string) (*FriendsListResponse, error) { accepted, err := s.friendRepo.ListAccepted(ctx, requesterUUID) if err != nil { return nil, fmt.Errorf("查询好友列表失败: %w", err) } incoming, err := s.friendRepo.ListIncoming(ctx, requesterUUID) if err != nil { return nil, fmt.Errorf("查询收到的请求失败: %w", err) } outgoing, err := s.friendRepo.ListOutgoing(ctx, requesterUUID) if err != nil { return nil, fmt.Errorf("查询发出的请求失败: %w", err) } // 批量解析 profile name friends, err := s.toFriendDTOs(ctx, accepted) if err != nil { return nil, err } incomingDTOs, err := s.toFriendDTOs(ctx, incoming) if err != nil { return nil, err } outgoingDTOs, err := s.toFriendDTOs(ctx, outgoing) if err != nil { return nil, err } return &FriendsListResponse{ Friends: friends, IncomingRequests: incomingDTOs, OutgoingRequests: outgoingDTOs, }, nil } // PerformFriendAction 执行好友加/删操作(文档 1.3.2) func (s *friendsService) PerformFriendAction(ctx context.Context, requesterUUID string, req FriendActionRequest) (*FriendsListResponse, error) { switch req.UpdateType { case FriendActionAdd: if err := s.addFriend(ctx, requesterUUID, req); err != nil { return nil, err } case FriendActionRemove: if err := s.removeFriend(ctx, requesterUUID, req); err != nil { return nil, err } default: return nil, apperrors.NewBadRequest("无效的 updateType", nil) } // 文档 1.3.2:PUT 成功后返回最新好友列表 return s.ListFriends(ctx, requesterUUID) } // addFriend 添加好友 / 发送请求 / 接受请求(文档 1.3.2 ADD) func (s *friendsService) addFriend(ctx context.Context, requesterUUID string, req FriendActionRequest) error { targetUUID, err := s.resolveTargetUUID(ctx, req) if err != nil { return err } // 校验目标存在 targetProfile, err := s.profileRepo.FindByUUID(ctx, targetUUID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) } return fmt.Errorf("查询目标档案失败: %w", err) } if targetProfile == nil { return apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) } // 不能添加自己 if requesterUUID == targetUUID { return apperrors.NewBadRequest(apperrors.FriendsErrCannotAddSelf, nil) } // 检查 requester->target 已有记录 existing, err := s.friendRepo.FindRelation(ctx, requesterUUID, targetUUID) if err != nil { return fmt.Errorf("查询已有关系失败: %w", err) } if existing != nil { switch existing.Status { case model.FriendsStatusAccepted: // 已是好友,幂等返回成功 return nil case model.FriendsStatusBlocked: // requester 屏蔽了 target,解除屏蔽并建好友 if err := s.friendRepo.Delete(ctx, existing.ID); err != nil { return fmt.Errorf("解除屏蔽失败: %w", err) } case model.FriendsStatusPending: // 已发过请求,幂等返回 return nil } } // 检查 target->requester 是否已向我发出 pending 请求 -> 自动接受 reverse, err := s.friendRepo.FindRelation(ctx, targetUUID, requesterUUID) if err != nil { return fmt.Errorf("查询反向关系失败: %w", err) } if reverse != nil { switch reverse.Status { case model.FriendsStatusPending: // 对方已向我发起请求,直接互为好友(更新 A→B 为 accepted 即可, // ListAccepted 会合并两方向,无需补建 B→A 以避免好友列表重复) if err := s.friendRepo.UpdateStatus(ctx, reverse.ID, model.FriendsStatusAccepted); err != nil { return fmt.Errorf("接受好友请求失败: %w", err) } return nil case model.FriendsStatusAccepted: // 对方已记录为好友(数据修复场景),确保 requester->target 也存在 accepted 记录 if existing == nil { if err := s.friendRepo.Create(ctx, &model.Friend{ RequesterUUID: requesterUUID, TargetUUID: targetUUID, Status: model.FriendsStatusAccepted, }); err != nil { return fmt.Errorf("创建好友记录失败: %w", err) } } return nil case model.FriendsStatusBlocked: // 对方屏蔽了我,不接受好友请求 return apperrors.NewForbidden("对方已屏蔽你") } } // 普通发送请求 if existing == nil { if err := s.friendRepo.Create(ctx, &model.Friend{ RequesterUUID: requesterUUID, TargetUUID: targetUUID, Status: model.FriendsStatusPending, }); err != nil { return fmt.Errorf("发送好友请求失败: %w", err) } } return nil } // removeFriend 删除好友 / 拒绝请求 / 撤回请求(文档 1.3.2 REMOVE) func (s *friendsService) removeFriend(ctx context.Context, requesterUUID string, req FriendActionRequest) error { targetUUID, err := s.resolveTargetUUID(ctx, req) if err != nil { return err } // 删除 requester->target 记录(涵盖撤回发出的请求、删除好友) if rel, err := s.friendRepo.FindRelation(ctx, requesterUUID, targetUUID); err != nil { return fmt.Errorf("查询关系失败: %w", err) } else if rel != nil { if err := s.friendRepo.Delete(ctx, rel.ID); err != nil { return fmt.Errorf("删除关系失败: %w", err) } } // 拒绝收到的请求时(requester 作为 target 方),删除 target->requester 的 pending 记录 if rel, err := s.friendRepo.FindRelation(ctx, targetUUID, requesterUUID); err != nil { return fmt.Errorf("查询反向关系失败: %w", err) } else if rel != nil && rel.Status == model.FriendsStatusPending { if err := s.friendRepo.Delete(ctx, rel.ID); err != nil { return fmt.Errorf("拒绝请求失败: %w", err) } } return nil } // resolveTargetUUID 解析请求目标 UUID:profileId 优先,否则用 name 查 Profile func (s *friendsService) resolveTargetUUID(ctx context.Context, req FriendActionRequest) (string, error) { if req.ProfileID != "" { return utils.FormatUUIDToNoDash(req.ProfileID), nil } if req.Name != "" { profile, err := s.profileRepo.FindByName(ctx, req.Name) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) } return "", fmt.Errorf("查询目标档案失败: %w", err) } if profile == nil { return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) } return profile.UUID, nil } return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil) } // toFriendDTOs 将 UUID 列表批量解析为 FriendDTO(补 name) func (s *friendsService) toFriendDTOs(ctx context.Context, uuids []string) ([]FriendDTO, error) { if len(uuids) == 0 { return []FriendDTO{}, nil } profiles, err := s.profileRepo.FindByUUIDs(ctx, uuids) if err != nil { return nil, fmt.Errorf("批量查询档案失败: %w", err) } nameByUUID := make(map[string]string, len(profiles)) for _, p := range profiles { if p != nil { nameByUUID[p.UUID] = p.Name } } result := make([]FriendDTO, 0, len(uuids)) for _, uid := range uuids { result = append(result, FriendDTO{ ProfileID: uid, Name: nameByUUID[uid], // 找不到 name 时留空 }) } return result, nil } // ============================================================================ // 玩家偏好属性 // ============================================================================ // GetAttributes 获取玩家属性(文档 1.2.1) func (s *friendsService) GetAttributes(ctx context.Context, profileUUID string) (*PlayerAttributesResponse, error) { attr, err := s.attrRepo.Get(ctx, profileUUID) if err != nil { return nil, fmt.Errorf("查询玩家偏好失败: %w", err) } if attr == nil { d := model.DefaultPlayerAttributes(profileUUID) attr = &d } return &PlayerAttributesResponse{ ProfanityFilterPreferences: ProfanityFilterPreferencesResp{ ProfanityFilterOn: attr.ProfanityFilterOn, }, FriendsPreferences: FriendsPreferencesResp{ Friends: enabledToString(attr.FriendsEnabled), AcceptInvites: enabledToString(attr.AcceptInvites), }, ChatPreferences: ChatPreferencesResp{ TextCommunication: attr.TextCommunication, }, }, nil } // UpdateAttributes 更新玩家偏好(文档 1.2.2) func (s *friendsService) UpdateAttributes(ctx context.Context, profileUUID string, req PlayerAttributesRequest) error { // 先读取现有值(保留未传字段) attr, err := s.attrRepo.Get(ctx, profileUUID) if err != nil { return fmt.Errorf("查询玩家偏好失败: %w", err) } if attr == nil { d := model.DefaultPlayerAttributes(profileUUID) attr = &d } if req.ProfanityFilterPreferences != nil && req.ProfanityFilterPreferences.ProfanityFilterOn != nil { attr.ProfanityFilterOn = *req.ProfanityFilterPreferences.ProfanityFilterOn } if req.FriendsPreferences != nil { if req.FriendsPreferences.Friends != nil { attr.FriendsEnabled = *req.FriendsPreferences.Friends == "ENABLED" } if req.FriendsPreferences.AcceptInvites != nil { attr.AcceptInvites = *req.FriendsPreferences.AcceptInvites == "ENABLED" } } if err := s.attrRepo.Upsert(ctx, attr); err != nil { return fmt.Errorf("更新玩家偏好失败: %w", err) } return nil } // enabledToString 将布尔偏好转为 ENABLED/DISABLED 字符串 func enabledToString(enabled bool) string { if enabled { return "ENABLED" } return "DISABLED" } // ============================================================================ // 在线状态 // ============================================================================ // UpdatePresence 上报自身在线状态并返回好友在线状态(文档 1.1) func (s *friendsService) UpdatePresence(ctx context.Context, requesterUUID string, status string) (*PresenceResponse, error) { // 写入自身状态到 Redis key := PresenceKeyPrefix + requesterUUID now := time.Now().UTC().Format(time.RFC3339Nano) // 存储 "status|lastUpdated",便于读取时一并恢复时间戳 value := status + "|" + now if err := s.redis.Set(ctx, key, value, s.presenceTTL); err != nil { s.logger.Error("写入在线状态失败", zap.Error(err), zap.String("profileUUID", requesterUUID), ) return nil, fmt.Errorf("写入在线状态失败: %w", err) } // 拉取好友 UUID 列表 friendUUIDs, err := s.friendRepo.ListAccepted(ctx, requesterUUID) if err != nil { return nil, fmt.Errorf("查询好友列表失败: %w", err) } entries := s.batchGetPresence(ctx, friendUUIDs) // 文档 1.1:服务器仅返回非离线玩家 filtered := make([]PresenceEntry, 0, len(entries)) for _, e := range entries { if e.Status != "" && e.Status != PresenceStatusOffline { filtered = append(filtered, e) } } return &PresenceResponse{Presence: filtered}, nil } // batchGetPresence 批量读取多个 profileUUID 的在线状态 func (s *friendsService) batchGetPresence(ctx context.Context, uuids []string) []PresenceEntry { if len(uuids) == 0 || s.redis == nil { return []PresenceEntry{} } keys := make([]string, 0, len(uuids)) for _, uid := range uuids { keys = append(keys, PresenceKeyPrefix+uid) } // 使用底层 go-redis 客户端的 MGet 一次读取 results, err := s.redis.Client.MGet(ctx, keys...).Result() if err != nil { s.logger.Warn("批量读取在线状态失败", zap.Error(err)) return []PresenceEntry{} } entries := make([]PresenceEntry, 0, len(uuids)) for i, uid := range uuids { var entry PresenceEntry entry.ProfileID = uid if i < len(results) && results[i] != nil { if raw, ok := results[i].(string); ok && raw != "" { parts := strings.SplitN(raw, "|", 2) entry.Status = parts[0] if len(parts) == 2 { entry.LastUpdated = parts[1] } else { entry.LastUpdated = time.Now().UTC().Format(time.RFC3339Nano) } } } entries = append(entries, entry) } return entries } // ============================================================================ // 屏蔽列表 // ============================================================================ // GetBlocklist 获取屏蔽列表(文档 1.6,含 120s 客户端节流缓存) func (s *friendsService) GetBlocklist(ctx context.Context, blockerUUID string) (*BlockListResponse, error) { // 文档 1.6:客户端 120s 节流;服务端在此也做一层缓存,避免重复查库 cacheKey := BlocklistCacheKeyPrefix + blockerUUID if s.redis != nil { if cached, err := s.redis.GetBytes(ctx, cacheKey); err == nil && len(cached) > 0 { var resp BlockListResponse if err := json.Unmarshal(cached, &resp); err == nil { return &resp, nil } } } blocked, err := s.friendRepo.ListBlocked(ctx, blockerUUID) if err != nil { return nil, fmt.Errorf("查询屏蔽列表失败: %w", err) } if blocked == nil { blocked = []string{} } resp := &BlockListResponse{BlockedProfiles: blocked} // 写入缓存 if s.redis != nil { if data, err := json.Marshal(resp); err == nil { if err := s.redis.Set(ctx, cacheKey, data, s.blocklistTTL); err != nil { s.logger.Warn("缓存屏蔽列表失败", zap.Error(err)) } } } return resp, nil } // 屏蔽/取消屏蔽方法(供后续/管理端调用,当前协议未定义对应端点) // Block 屏蔽目标玩家 func (s *friendsService) Block(ctx context.Context, blockerUUID, targetUUID string) error { if blockerUUID == targetUUID { return apperrors.NewBadRequest(apperrors.FriendsErrCannotAddSelf, nil) } // 若已有关系记录,更新为 blocked;否则新建 if rel, err := s.friendRepo.FindRelation(ctx, blockerUUID, targetUUID); err != nil { return fmt.Errorf("查询关系失败: %w", err) } else if rel != nil { if err := s.friendRepo.UpdateStatus(ctx, rel.ID, model.FriendsStatusBlocked); err != nil { return fmt.Errorf("更新屏蔽状态失败: %w", err) } return s.invalidateBlocklistCache(ctx, blockerUUID) } if err := s.friendRepo.Create(ctx, &model.Friend{ RequesterUUID: blockerUUID, TargetUUID: targetUUID, Status: model.FriendsStatusBlocked, }); err != nil { return fmt.Errorf("创建屏蔽记录失败: %w", err) } return s.invalidateBlocklistCache(ctx, blockerUUID) } // Unblock 取消屏蔽 func (s *friendsService) Unblock(ctx context.Context, blockerUUID, targetUUID string) error { rel, err := s.friendRepo.FindRelation(ctx, blockerUUID, targetUUID) if err != nil { return fmt.Errorf("查询关系失败: %w", err) } if rel == nil || rel.Status != model.FriendsStatusBlocked { // 幂等:未屏蔽时直接返回 return nil } if err := s.friendRepo.Delete(ctx, rel.ID); err != nil { return fmt.Errorf("取消屏蔽失败: %w", err) } return s.invalidateBlocklistCache(ctx, blockerUUID) } // invalidateBlocklistCache 清除屏蔽列表缓存 func (s *friendsService) invalidateBlocklistCache(ctx context.Context, blockerUUID string) error { if s.redis == nil { return nil } return s.redis.Del(ctx, BlocklistCacheKeyPrefix+blockerUUID) } // 确保引用了 go-redis(MGet 通过 s.redis.Client) var _ = redis.Client{}