package service import ( "errors" "math" "sort" "time" "gorm.io/gorm" "dating-app/internal/dto" "dating-app/internal/models" "dating-app/internal/repository" "dating-app/internal/utils" ) const ( DefaultMinAge = 18 DefaultMaxAge = 60 DiscoverBatchSize = 60 DiscoverResultSize = 20 ) var ( ErrGenderNotSet = errors.New("برای دیدن پیشنهادها اول باید جنسیتت مشخص شده باشه") ErrCannotSwipeSelf = errors.New("نمی‌تونی روی خودت سواپ بزنی") ErrAlreadySwiped = errors.New("قبلاً روی این پروفایل تصمیم گرفتی") ) // جنسیت مخالف کاربر رو برمی‌گردونه. این ساده‌ترین فرض ممکنه (هترو دیفالت)؛ // سیستم فعلاً فیلد جداگانه‌ای برای «ترجیح جنسیتی/گرایش» نداره. func oppositeGender(g models.Gender) models.Gender { if g == models.GenderFemale { return models.GenderMale } return models.GenderFemale } type DiscoveryService struct { userRepo *repository.UserRepository photoRepo *repository.PhotoRepository swipeRepo *repository.SwipeRepository matchRepo *repository.MatchRepository optionRepo *repository.OptionRepository questionRepo *repository.QuestionRepository } func NewDiscoveryService( userRepo *repository.UserRepository, photoRepo *repository.PhotoRepository, swipeRepo *repository.SwipeRepository, matchRepo *repository.MatchRepository, optionRepo *repository.OptionRepository, questionRepo *repository.QuestionRepository, ) *DiscoveryService { return &DiscoveryService{ userRepo: userRepo, photoRepo: photoRepo, swipeRepo: swipeRepo, matchRepo: matchRepo, optionRepo: optionRepo, questionRepo: questionRepo, } } type candidateWithDistance struct { user models.User distance *float64 } // GetFeed لیست پیشنهادهای صفحه‌ی دوستیابی رو برمی‌گردونه؛ نزدیک‌ترین‌ها اول. func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, minAge, maxAge int) ([]dto.DiscoverCandidate, error) { if user.Gender == nil { return nil, ErrGenderNotSet } if minAge <= 0 { minAge = DefaultMinAge } if maxAge <= 0 || maxAge < minAge { maxAge = DefaultMaxAge } candidates, err := s.userRepo.ListDiscoverable(user.ID, oppositeGender(*user.Gender), minAge, maxAge, DiscoverBatchSize) if err != nil { return nil, err } if len(candidates) == 0 { return []dto.DiscoverCandidate{}, nil } ids := make([]uint, len(candidates)) for i, c := range candidates { ids[i] = c.ID } photos, err := s.photoRepo.ListByUserIDs(ids) if err != nil { return nil, err } photosByUser := map[uint][]string{} for _, p := range photos { photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL) } interestsByUser := s.batchInterests(ids) withDist := make([]candidateWithDistance, 0, len(candidates)) for _, cand := range candidates { var dist *float64 if user.Latitude != nil && user.Longitude != nil && cand.Latitude != nil && cand.Longitude != nil { km := math.Round(utils.HaversineKM(*user.Latitude, *user.Longitude, *cand.Latitude, *cand.Longitude)) dist = &km } if maxDistanceKM != nil && dist != nil && *dist > *maxDistanceKM { continue } withDist = append(withDist, candidateWithDistance{user: cand, distance: dist}) } sort.SliceStable(withDist, func(i, j int) bool { if withDist[i].distance == nil { return false } if withDist[j].distance == nil { return true } return *withDist[i].distance < *withDist[j].distance }) if len(withDist) > DiscoverResultSize { withDist = withDist[:DiscoverResultSize] } result := make([]dto.DiscoverCandidate, 0, len(withDist)) for _, wd := range withDist { result = append(result, dto.DiscoverCandidate{ ID: wd.user.ID, FirstName: wd.user.FirstName, Age: utils.CalcAge(wd.user.BirthDate), Photos: photosByUser[wd.user.ID], DistanceKM: wd.distance, Interests: interestsByUser[wd.user.ID], }) } return result, nil } func (s *DiscoveryService) batchInterests(userIDs []uint) map[uint][]string { result := map[uint][]string{} cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest) if err != nil { return result } for _, id := range userIDs { sels, err := s.optionRepo.GetUserSelections(id, cat.ID) if err != nil { continue } for _, sel := range sels { if sel.Option != nil { result[id] = append(result[id], sel.Option.Title) } } } return result } // Swipe یک تصمیم (لایک/ردّ/سوپرلایک) ثبت می‌کنه و در صورت لایک متقابل، مچ می‌سازه. func (s *DiscoveryService) Swipe(user *models.User, targetID uint, action models.SwipeAction) (*dto.SwipeResponse, error) { if targetID == user.ID { return nil, ErrCannotSwipeSelf } target, err := s.userRepo.FindByID(targetID) if err != nil { return nil, err } alreadySwiped, err := s.swipeRepo.Exists(user.ID, targetID) if err != nil { return nil, err } if alreadySwiped { return nil, ErrAlreadySwiped } if err := s.swipeRepo.Create(&models.Swipe{SwiperID: user.ID, SwipedID: targetID, Action: action}); err != nil { return nil, err } if action == models.SwipePass { return &dto.SwipeResponse{Matched: false}, nil } theyLikedMe, err := s.swipeRepo.HasLiked(targetID, user.ID) if err != nil { return nil, err } if !theyLikedMe { return &dto.SwipeResponse{Matched: false}, nil } match, _, err := s.matchRepo.FindOrCreate(user.ID, targetID) if err != nil { return nil, err } photos, _ := s.photoRepo.ListByUser(target.ID) urls := make([]string, len(photos)) for i, p := range photos { urls[i] = p.URL } return &dto.SwipeResponse{ Matched: true, Match: &dto.MatchView{ ID: match.ID, UserID: target.ID, FirstName: target.FirstName, Photos: urls, CreatedAt: match.CreatedAt.Format(time.RFC3339), }, }, nil } // GetCandidateDetail پروفایل کامل یک کاربر دیگه رو برای نمایش صفحه‌ی جزئیات برمی‌گردونه func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint) (*dto.CandidateDetail, error) { target, err := s.userRepo.FindByID(targetID) if err != nil { return nil, err } if !target.IsOnboarded || target.ApprovalStatus != models.ApprovalApproved || !target.IsActive { return nil, gorm.ErrRecordNotFound } photos, _ := s.photoRepo.ListByUser(target.ID) urls := make([]string, len(photos)) for i, p := range photos { urls[i] = p.URL } var distance *float64 if viewer.Latitude != nil && viewer.Longitude != nil && target.Latitude != nil && target.Longitude != nil { km := math.Round(utils.HaversineKM(*viewer.Latitude, *viewer.Longitude, *target.Latitude, *target.Longitude)) distance = &km } detail := &dto.CandidateDetail{ ID: target.ID, FirstName: target.FirstName, Age: utils.CalcAge(target.BirthDate), Photos: urls, DistanceKM: distance, HeightCM: target.HeightCM, WeightKG: target.WeightKG, } detail.Education = s.firstSelectionTitle(target.ID, models.CategoryEducationLevel) detail.FieldOfStudy = s.firstSelectionTitle(target.ID, models.CategoryFieldOfStudy) detail.LookingFor = s.firstSelectionTitle(target.ID, models.CategoryLookingFor) detail.Occupations = s.allSelectionTitles(target.ID, models.CategoryOccupation) detail.Interests = s.allSelectionTitles(target.ID, models.CategoryInterest) detail.ImportantInRelationship = s.allSelectionTitles(target.ID, models.CategoryImportantInRelationship) if answers, err := s.questionRepo.GetUserAnswers(target.ID); err == nil { for _, a := range answers { if a.Question == nil { continue } detail.Answers = append(detail.Answers, dto.CandidateAnswer{ Question: a.Question.Text, Answer: a.Answer, }) } } return detail, nil } func (s *DiscoveryService) allSelectionTitles(userID uint, key models.CategoryKey) []string { cat, err := s.optionRepo.GetCategoryByKey(key) if err != nil { return nil } sels, err := s.optionRepo.GetUserSelections(userID, cat.ID) if err != nil { return nil } titles := make([]string, 0, len(sels)) for _, sel := range sels { if sel.Option != nil { titles = append(titles, sel.Option.Title) } } return titles } func (s *DiscoveryService) firstSelectionTitle(userID uint, key models.CategoryKey) string { titles := s.allSelectionTitles(userID, key) if len(titles) == 0 { return "" } return titles[0] }