Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
348 lines
15 KiB
Java
348 lines
15 KiB
Java
package de.oaa.xxx.dating;
|
||
|
||
import de.oaa.xxx.user.Geschlecht;
|
||
import de.oaa.xxx.user.Neigung;
|
||
import de.oaa.xxx.user.UserEntity;
|
||
import de.oaa.xxx.user.UserRepository;
|
||
import de.oaa.xxx.vorlieben.UserVorliebeEntity;
|
||
import de.oaa.xxx.vorlieben.UserVorliebeRepository;
|
||
import de.oaa.xxx.vorlieben.VorliebeBewertung;
|
||
import org.springframework.stereotype.Service;
|
||
import org.springframework.transaction.annotation.Transactional;
|
||
|
||
import java.time.LocalDateTime;
|
||
import java.util.*;
|
||
import java.util.function.Function;
|
||
import java.util.stream.Collectors;
|
||
|
||
@Service
|
||
public class DatingService {
|
||
|
||
private static final Set<VorliebeBewertung> POSITIVE_RATINGS = Set.of(
|
||
VorliebeBewertung.MAG_ICH,
|
||
VorliebeBewertung.UNBEDINGT,
|
||
VorliebeBewertung.WILL_AUSPROBIEREN
|
||
);
|
||
|
||
private final UserRepository userRepository;
|
||
private final UserVorliebeRepository userVorliebeRepository;
|
||
private final DatingLikeRepository likeRepository;
|
||
private final DatingMatchRepository matchRepository;
|
||
private final DatingPassRepository passRepository;
|
||
|
||
public DatingService(UserRepository userRepository,
|
||
UserVorliebeRepository userVorliebeRepository,
|
||
DatingLikeRepository likeRepository,
|
||
DatingMatchRepository matchRepository,
|
||
DatingPassRepository passRepository) {
|
||
this.userRepository = userRepository;
|
||
this.userVorliebeRepository = userVorliebeRepository;
|
||
this.likeRepository = likeRepository;
|
||
this.matchRepository = matchRepository;
|
||
this.passRepository = passRepository;
|
||
}
|
||
|
||
// ── Records ──────────────────────────────────────────────────────────────
|
||
|
||
record DatingFilter(
|
||
int maxDistanceKm,
|
||
int minAge,
|
||
int maxAge,
|
||
List<Geschlecht> geschlechter,
|
||
List<Neigung> neigungen,
|
||
List<UUID> vorliebenIds,
|
||
boolean vorliebenUnd
|
||
) {}
|
||
|
||
record DatingProfileDto(
|
||
UUID userId,
|
||
String name,
|
||
Integer alter,
|
||
double distanzKm,
|
||
String profilePicture,
|
||
String neigung,
|
||
String geschlecht,
|
||
String datingStadt,
|
||
String beschreibung,
|
||
boolean likedByMe
|
||
) {}
|
||
|
||
record LikeResult(boolean liked, boolean newMatch) {}
|
||
|
||
record LikerDto(UUID userId, String name, String profilePicture, LocalDateTime likedAt) {}
|
||
|
||
record WhoLikesMeResult(boolean premium, int total, List<LikerDto> likers) {}
|
||
|
||
record MatchDto(UUID userId, String name, String profilePicture, LocalDateTime matchedAt) {}
|
||
|
||
record IdsResult(List<UUID> ids, int total) {}
|
||
|
||
// ── Public API ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Liefert alle passenden User-IDs, nach Entfernung sortiert.
|
||
* Kein Paging – die Liste wird komplett zurückgegeben, das Frontend
|
||
* holt Profildetails nachgelagert in Batches.
|
||
*/
|
||
public IdsResult findSortedIds(UUID currentUserId, DatingFilter filter,
|
||
double myLat, double myLon) {
|
||
List<UserEntity> candidates = userRepository.findByDatingAktiv(true).stream()
|
||
.filter(u -> !u.getUserId().equals(currentUserId))
|
||
.filter(u -> u.getDatingLat() != null && u.getDatingLon() != null)
|
||
.toList();
|
||
|
||
candidates = filterByDistance(candidates, myLat, myLon, filter.maxDistanceKm());
|
||
candidates = filterByAge(candidates, filter.minAge(), filter.maxAge());
|
||
|
||
if (!filter.geschlechter().isEmpty()) {
|
||
candidates = candidates.stream()
|
||
.filter(u -> u.getGeschlecht() != null && filter.geschlechter().contains(u.getGeschlecht()))
|
||
.toList();
|
||
}
|
||
if (!filter.neigungen().isEmpty()) {
|
||
candidates = candidates.stream()
|
||
.filter(u -> u.getNeigung() != null && filter.neigungen().contains(u.getNeigung()))
|
||
.toList();
|
||
}
|
||
if (!filter.vorliebenIds().isEmpty()) {
|
||
candidates = filterByVorlieben(candidates, filter.vorliebenIds(), filter.vorliebenUnd());
|
||
}
|
||
|
||
List<UUID> sorted = candidates.stream()
|
||
.sorted(Comparator.comparingDouble(u ->
|
||
haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon())))
|
||
.map(UserEntity::getUserId)
|
||
.toList();
|
||
|
||
return new IdsResult(sorted, sorted.size());
|
||
}
|
||
|
||
/**
|
||
* Lädt Profildetails für die übergebenen IDs und behält deren Reihenfolge bei.
|
||
*/
|
||
public List<DatingProfileDto> getProfilesByIds(List<UUID> ids, UUID currentUserId, double myLat, double myLon) {
|
||
if (ids == null || ids.isEmpty()) return List.of();
|
||
|
||
Map<UUID, UserEntity> byId = userRepository.findAllById(ids).stream()
|
||
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
|
||
|
||
Set<UUID> likedByMe = likeRepository.findByLikerId(currentUserId).stream()
|
||
.map(DatingLikeEntity::getLikedId)
|
||
.collect(Collectors.toSet());
|
||
|
||
return ids.stream()
|
||
.map(byId::get)
|
||
.filter(Objects::nonNull)
|
||
.map(u -> toDto(u, currentUserId, likedByMe, myLat, myLon))
|
||
.toList();
|
||
}
|
||
|
||
// ── Like / Match ──────────────────────────────────────────────────────────
|
||
|
||
@Transactional
|
||
public LikeResult toggleLike(UUID likerId, UUID likedId) {
|
||
if (likeRepository.existsByLikerIdAndLikedId(likerId, likedId)) {
|
||
likeRepository.deleteByLikerIdAndLikedId(likerId, likedId);
|
||
matchRepository.deleteByUsers(likerId, likedId);
|
||
return new LikeResult(false, false);
|
||
}
|
||
|
||
DatingLikeEntity like = new DatingLikeEntity();
|
||
like.setLikeId(UUID.randomUUID());
|
||
like.setLikerId(likerId);
|
||
like.setLikedId(likedId);
|
||
like.setLikedAt(LocalDateTime.now());
|
||
likeRepository.save(like);
|
||
|
||
boolean mutual = likeRepository.existsByLikerIdAndLikedId(likedId, likerId);
|
||
boolean newMatch = false;
|
||
if (mutual && !matchRepository.existsByUsers(likerId, likedId)) {
|
||
DatingMatchEntity match = new DatingMatchEntity();
|
||
match.setMatchId(UUID.randomUUID());
|
||
match.setUser1Id(likerId);
|
||
match.setUser2Id(likedId);
|
||
match.setMatchedAt(LocalDateTime.now());
|
||
matchRepository.save(match);
|
||
newMatch = true;
|
||
}
|
||
return new LikeResult(true, newMatch);
|
||
}
|
||
|
||
public WhoLikesMeResult whoLikesMe(UUID userId, boolean isPremium) {
|
||
List<DatingLikeEntity> likes = likeRepository.findByLikedId(userId);
|
||
int total = likes.size();
|
||
|
||
if (total == 0) return new WhoLikesMeResult(isPremium, 0, List.of());
|
||
|
||
Map<UUID, UserEntity> byId = userRepository.findAllById(
|
||
likes.stream().map(DatingLikeEntity::getLikerId).toList()).stream()
|
||
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
|
||
|
||
List<LikerDto> likers = likes.stream()
|
||
.sorted(Comparator.comparing(DatingLikeEntity::getLikedAt).reversed())
|
||
.map(l -> {
|
||
UserEntity u = byId.get(l.getLikerId());
|
||
if (u == null) return null;
|
||
return new LikerDto(
|
||
isPremium ? u.getUserId() : null,
|
||
isPremium ? u.getName() : null,
|
||
u.getProfilePicture(),
|
||
l.getLikedAt());
|
||
})
|
||
.filter(Objects::nonNull)
|
||
.toList();
|
||
|
||
return new WhoLikesMeResult(isPremium, total, likers);
|
||
}
|
||
|
||
public List<MatchDto> getMatches(UUID userId) {
|
||
List<DatingMatchEntity> matches = matchRepository.findByUser(userId);
|
||
// PartnerId ermitteln
|
||
Map<UUID, UserEntity> byId = userRepository.findAllById(
|
||
matches.stream()
|
||
.map(m -> m.getUser1Id().equals(userId) ? m.getUser2Id() : m.getUser1Id())
|
||
.toList()).stream()
|
||
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
|
||
|
||
return matches.stream()
|
||
.sorted(Comparator.comparing(DatingMatchEntity::getMatchedAt).reversed())
|
||
.map(m -> {
|
||
UUID partnerId = m.getUser1Id().equals(userId) ? m.getUser2Id() : m.getUser1Id();
|
||
UserEntity u = byId.get(partnerId);
|
||
if (u == null) return null;
|
||
return new MatchDto(u.getUserId(), u.getName(), u.getProfilePicture(), m.getMatchedAt());
|
||
})
|
||
.filter(Objects::nonNull)
|
||
.toList();
|
||
}
|
||
|
||
public List<UUID> getLikedByMe(UUID userId) {
|
||
return likeRepository.findByLikerId(userId).stream()
|
||
.map(DatingLikeEntity::getLikedId)
|
||
.toList();
|
||
}
|
||
|
||
/**
|
||
* Liefert zufällig sortierte IDs für den Discovery-Modus (Match-Tab).
|
||
* Bedingung: gegenseitige Geschlechter-Kompatibilität, Distanz, noch nicht geliked.
|
||
*/
|
||
public List<UUID> findDiscoveryIds(UserEntity me) {
|
||
Set<String> myGeschlechter = me.getDatingGeschlechter() != null && !me.getDatingGeschlechter().isBlank()
|
||
? Set.of(me.getDatingGeschlechter().split(","))
|
||
: Set.of();
|
||
|
||
List<UserEntity> candidates = userRepository.findByDatingAktiv(true).stream()
|
||
.filter(u -> !u.getUserId().equals(me.getUserId()))
|
||
.filter(u -> u.getDatingLat() != null && u.getDatingLon() != null)
|
||
.toList();
|
||
|
||
// Ich muss an deren Geschlecht interessiert sein
|
||
if (!myGeschlechter.isEmpty()) {
|
||
candidates = candidates.stream()
|
||
.filter(u -> u.getGeschlecht() != null
|
||
&& myGeschlechter.contains(u.getGeschlecht().name()))
|
||
.toList();
|
||
}
|
||
|
||
// Sie müssen an meinem Geschlecht interessiert sein (oder keine Einschränkung haben)
|
||
if (me.getGeschlecht() != null) {
|
||
final String myGender = me.getGeschlecht().name();
|
||
candidates = candidates.stream()
|
||
.filter(u -> {
|
||
if (u.getDatingGeschlechter() == null || u.getDatingGeschlechter().isBlank()) return true;
|
||
return Set.of(u.getDatingGeschlechter().split(",")).contains(myGender);
|
||
})
|
||
.toList();
|
||
}
|
||
|
||
// Distanz: mein gespeicherter Max-Wert, Fallback 50 km
|
||
int maxDist = me.getDatingMaxDistanzKm() != null ? me.getDatingMaxDistanzKm() : 50;
|
||
candidates = filterByDistance(candidates, me.getDatingLat(), me.getDatingLon(), maxDist);
|
||
|
||
// Bereits gelikte und abgelehnte Profile ausschließen
|
||
Set<UUID> alreadyLiked = likeRepository.findByLikerId(me.getUserId()).stream()
|
||
.map(DatingLikeEntity::getLikedId)
|
||
.collect(Collectors.toSet());
|
||
Set<UUID> alreadyPassed = passRepository.findPassedIdsByPasserId(me.getUserId());
|
||
candidates = candidates.stream()
|
||
.filter(u -> !alreadyLiked.contains(u.getUserId()) && !alreadyPassed.contains(u.getUserId()))
|
||
.toList();
|
||
|
||
List<UUID> ids = new ArrayList<>(candidates.stream().map(UserEntity::getUserId).toList());
|
||
Collections.shuffle(ids);
|
||
return ids;
|
||
}
|
||
|
||
// ── Private helpers ───────────────────────────────────────────────────────
|
||
|
||
private List<UserEntity> filterByDistance(List<UserEntity> users, double myLat, double myLon, int maxKm) {
|
||
return users.stream()
|
||
.filter(u -> haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon()) <= maxKm)
|
||
.toList();
|
||
}
|
||
|
||
private List<UserEntity> filterByAge(List<UserEntity> users, int minAge, int maxAge) {
|
||
return users.stream()
|
||
.filter(u -> {
|
||
Integer age = u.getAlter();
|
||
if (age == null) return false;
|
||
return age >= minAge && age <= maxAge;
|
||
})
|
||
.toList();
|
||
}
|
||
|
||
private List<UserEntity> filterByVorlieben(List<UserEntity> candidates,
|
||
List<UUID> requiredItemIds,
|
||
boolean andLogic) {
|
||
Set<UUID> candidateIds = candidates.stream()
|
||
.map(UserEntity::getUserId)
|
||
.collect(Collectors.toSet());
|
||
|
||
Map<UUID, Set<UUID>> positiveByUser = userVorliebeRepository.findByUserIdIn(candidateIds).stream()
|
||
.filter(uv -> POSITIVE_RATINGS.contains(uv.getBewertung()))
|
||
.collect(Collectors.groupingBy(
|
||
UserVorliebeEntity::getUserId,
|
||
Collectors.mapping(UserVorliebeEntity::getItemId, Collectors.toSet())
|
||
));
|
||
|
||
Set<UUID> required = new HashSet<>(requiredItemIds);
|
||
return candidates.stream()
|
||
.filter(u -> {
|
||
Set<UUID> userItems = positiveByUser.getOrDefault(u.getUserId(), Set.of());
|
||
return andLogic
|
||
? userItems.containsAll(required)
|
||
: required.stream().anyMatch(userItems::contains);
|
||
})
|
||
.toList();
|
||
}
|
||
|
||
private DatingProfileDto toDto(UserEntity u, UUID currentUserId, Set<UUID> likedByMe, double myLat, double myLon) {
|
||
double dist = Math.round(haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon()) * 10.0) / 10.0;
|
||
return new DatingProfileDto(
|
||
u.getUserId(),
|
||
u.getName(),
|
||
u.getAlter(),
|
||
dist,
|
||
u.getProfilePicture(),
|
||
u.getNeigung() != null ? u.getNeigung().getLabel() : null,
|
||
u.getGeschlecht() != null ? u.getGeschlecht().getLabel() : null,
|
||
u.getDatingStadt(),
|
||
u.getBeschreibung(),
|
||
likedByMe.contains(u.getUserId())
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Haversine-Formel: Luftlinie zwischen zwei Koordinaten in Kilometern.
|
||
*/
|
||
static double haversineKm(double lat1, double lon1, double lat2, double lon2) {
|
||
final double R = 6371.0;
|
||
double dLat = Math.toRadians(lat2 - lat1);
|
||
double dLon = Math.toRadians(lon2 - lon1);
|
||
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
||
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
}
|
||
}
|