chore: merge backend repo history into monorepo at backend/

git-subtree-dir: backend
git-subtree-mainline: dc11190527
git-subtree-split: acae976622
This commit is contained in:
2026-09-27 10:30:16 +03:30
67 changed files with 6835 additions and 0 deletions
@@ -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
}