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), // نبود عکس/علاقه‌مندی نباید توی JSON به‌صورت null دربیاد؛ کلاینت‌ها // همیشه یک آرایه (حتی خالی) انتظار دارن، نه null. Photos: orEmptyStrings(photosByUser[wd.user.ID]), DistanceKM: wd.distance, Interests: orEmptyStrings(interestsByUser[wd.user.ID]), }) } return result, nil } func orEmptyStrings(s []string) []string { if s == nil { return []string{} } return s } // batchInterests علاقه‌مندی‌های تمام کاندیداهای فید رو در یک کوئری واحد // می‌گیره (به‌جای یک کوئری جدا به‌ازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰ // کاربر با هم پردازش می‌شن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد می‌کنه. func (s *DiscoveryService) batchInterests(userIDs []uint) map[uint][]string { cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest) if err != nil { return map[uint][]string{} } result, err := s.optionRepo.GetSelectionsForUsersInCategory(userIDs, cat.ID) if err != nil { return map[uint][]string{} } 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, } titlesByCategory := s.selectionTitlesByCategory(target.ID, []models.CategoryKey{ models.CategoryEducationLevel, models.CategoryFieldOfStudy, models.CategoryLookingFor, models.CategoryOccupation, models.CategoryInterest, models.CategoryImportantInRelationship, }) first := func(titles []string) string { if len(titles) == 0 { return "" } return titles[0] } detail.Education = first(titlesByCategory[models.CategoryEducationLevel]) detail.FieldOfStudy = first(titlesByCategory[models.CategoryFieldOfStudy]) detail.LookingFor = first(titlesByCategory[models.CategoryLookingFor]) detail.Occupations = titlesByCategory[models.CategoryOccupation] detail.Interests = titlesByCategory[models.CategoryInterest] detail.ImportantInRelationship = titlesByCategory[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 } // selectionTitlesByCategory انتخاب‌های یک کاربر رو در چند دسته با هم، طی یک // کوئری واحد به دیتابیس می‌گیره (به‌جای یک کوئری جدا به‌ازای هر دسته که قبلاً // برای صفحه‌ی جزئیات پروفایل تا ۶ رفت‌وبرگشت جدا به دیتابیس می‌زد). // GetCategoryByKey از کش می‌خونه (چون دسته‌ها به‌ندرت تغییر می‌کنن) پس عملاً // فقط همین یک کوئری واقعی به دیتابیس زده می‌شه. func (s *DiscoveryService) selectionTitlesByCategory(userID uint, keys []models.CategoryKey) map[models.CategoryKey][]string { result := make(map[models.CategoryKey][]string, len(keys)) categoryIDToKey := make(map[uint]models.CategoryKey, len(keys)) categoryIDs := make([]uint, 0, len(keys)) for _, key := range keys { cat, err := s.optionRepo.GetCategoryByKey(key) if err != nil { continue } categoryIDToKey[cat.ID] = key categoryIDs = append(categoryIDs, cat.ID) } selectionsByCategoryID, err := s.optionRepo.GetUserSelectionsForCategories(userID, categoryIDs) if err != nil { return result } for categoryID, sels := range selectionsByCategoryID { key := categoryIDToKey[categoryID] for _, sel := range sels { if sel.Option != nil { result[key] = append(result[key], sel.Option.Title) } } } return result }