Weiter am Dating gearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-04 00:09:08 +02:00
parent 87c85b1b17
commit d386f5a7a9
61 changed files with 29863 additions and 350 deletions

View File

@@ -0,0 +1,347 @@
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));
}
}