chore: merge backend repo history into monorepo at backend/
git-subtree-dir: backend git-subtree-mainline:dc11190527git-subtree-split:acae976622
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
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),
|
||||
Bio: derefStr(wd.user.Bio),
|
||||
// نبود عکس/علاقهمندی نباید توی 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
|
||||
}
|
||||
|
||||
func derefStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
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),
|
||||
Bio: derefStr(target.Bio),
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user