Files
xxx-sphere-web/src/main/java/de/oaa/xxx/dating/DatingService.java
Mario d386f5a7a9
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Weiter am Dating gearbeitet
2026-04-04 00:09:08 +02:00

348 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}