diff --git a/src/main/java/com/api/saojeong/SoonOut/controller/SoonOutApiController.java b/src/main/java/com/api/saojeong/SoonOut/controller/SoonOutApiController.java index 14cb13f..ff383c3 100644 --- a/src/main/java/com/api/saojeong/SoonOut/controller/SoonOutApiController.java +++ b/src/main/java/com/api/saojeong/SoonOut/controller/SoonOutApiController.java @@ -20,7 +20,7 @@ @RequiredArgsConstructor public class SoonOutApiController { - private final AlertService alertService; + private final SoonOutService soonOutService; private final ParkingRepository parkingRepo; private final ReservationRepository reservationRepo; @@ -29,7 +29,7 @@ public ResponseEntity createSoonOut(CreateSoonOutRequestDto req) { Parking parking = (req.getParkingId() != null) ? parkingRepo.findById(req.getParkingId()).orElse(null) : null; Reservation res = (req.getReservationId()!= null) ? reservationRepo.findById(req.getReservationId()).orElse(null) : null; - Long id = alertService.createSoonOut(req.getLat(), req.getLat(), req.getMinute(), req.isStatus(), + Long id = soonOutService.createSoonOut(req.getLat(), req.getLng(), req.getMinute(), req.isStatus(), parking, req.getProvider(), req.getExternalId(), res, req.getPlaceName(), req.getAddress()); return ResponseEntity.ok(CustomApiResponse.createSuccess(HttpStatus.OK.value(), diff --git a/src/main/java/com/api/saojeong/SoonOut/respotiory/SoonOutRepository.java b/src/main/java/com/api/saojeong/SoonOut/respotiory/SoonOutRepository.java index f00b204..8fe20b6 100644 --- a/src/main/java/com/api/saojeong/SoonOut/respotiory/SoonOutRepository.java +++ b/src/main/java/com/api/saojeong/SoonOut/respotiory/SoonOutRepository.java @@ -29,4 +29,5 @@ public interface SoonOutRepository extends JpaRepository { AND TIMESTAMPADD(MINUTE, minute, created_at) <= :now """, nativeQuery = true) int deleteExpiredNow(@Param("now") LocalDateTime now); + } diff --git a/src/main/java/com/api/saojeong/SoonOut/service/SoonOutService.java b/src/main/java/com/api/saojeong/SoonOut/service/SoonOutService.java index 63dcbfc..cf3e837 100644 --- a/src/main/java/com/api/saojeong/SoonOut/service/SoonOutService.java +++ b/src/main/java/com/api/saojeong/SoonOut/service/SoonOutService.java @@ -11,6 +11,9 @@ import jakarta.validation.constraints.NotNull; public interface SoonOutService { + Long createSoonOut(Double lat, Double lng, int minute, boolean status, + Parking parking, String provider, String externalId, Reservation reservation, + String placeNameOptional,String address); // CreateSoonOutResponseDto creatSoonOut(Member member, Long reservationId, CreateSoonOutRequestDto req); //곧 나감 알림 취소 diff --git a/src/main/java/com/api/saojeong/SoonOut/service/SoonOutServiceImpl.java b/src/main/java/com/api/saojeong/SoonOut/service/SoonOutServiceImpl.java index acb0c00..c63e279 100644 --- a/src/main/java/com/api/saojeong/SoonOut/service/SoonOutServiceImpl.java +++ b/src/main/java/com/api/saojeong/SoonOut/service/SoonOutServiceImpl.java @@ -7,21 +7,230 @@ import com.api.saojeong.SoonOut.dto.CancelSoonOutResponseDto; import com.api.saojeong.SoonOut.exception.SoonOutNotFound; import com.api.saojeong.SoonOut.respotiory.SoonOutRepository; -import com.api.saojeong.domain.Member; -import com.api.saojeong.domain.Parking; -import com.api.saojeong.domain.Reservation; -import com.api.saojeong.domain.SoonOut; +import com.api.saojeong.alert.Enum.NotificationType; +import com.api.saojeong.alert.repository.AlertSubscriptionRepository; +import com.api.saojeong.alert.repository.NotificationEventRepository; +import com.api.saojeong.alert.service.NotificationService; +import com.api.saojeong.domain.*; +import com.api.saojeong.memberLocation.repository.MemberLocationRepository; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class SoonOutServiceImpl implements SoonOutService { - private final ReservationRepository reservationRepository; - private final SoonOutRepository soonOutRepository; + private static final double KM_1 = 1000.0; + private static final double KM_2 = 2000.0; + private static final Duration LOCATION_TTL = Duration.ofMinutes(15); + + private final NotificationEventRepository eventRepo; + private final SoonOutRepository soonRepo; + private final MemberLocationRepository locationRepo; + private final NotificationService notifier; + private final AlertSubscriptionRepository subRepo; + + +// private final ReservationRepository reservationRepository; +// private final SoonOutRepository soonOutRepository; + + //곧나감추가 & 알림 발송 + @Transactional + public Long createSoonOut(Double lat, Double lng, int minute, boolean status, + Parking parking, String provider, String externalId, Reservation reservation, + String placeNameOptional,String address) { + + log.info("[SOONOUT][REQ] lat={}, lng={}, minute={}, status={}, parkingId={}, provider={}, externalId={}, reservationId={}, placeName={}", + lat, lng, minute, status, + parking != null ? parking.getId() : null, + provider, externalId, + reservation != null ? reservation.getId() : null, + placeNameOptional); + + + SoonOut so ; + //개인이면 + if(parking !=null){ + so = SoonOut.builder() + .lat(lat) + .lng(lng) + .minute(minute) + .status(status) + .parking(parking) + .reservation(reservation) + .placeName(placeNameOptional) + .address(address) + .build(); + } + else { //공영,민영 + + so = SoonOut.builder() + .lat(lat).lng(lng) + .minute(minute) + .status(status) + .provider(provider) + .externalId(externalId) + .reservation(reservation) + .placeName(placeNameOptional) + .build(); + } + so = soonRepo.save(so); + + + + // 대상 구독자 (현재 로직 유지) + List subs = Collections.emptyList(); + if (provider != null && !provider.isBlank() && externalId != null && !externalId.isBlank()) { + subs = subRepo.findByProviderAndExternalIdAndActiveIsTrue(provider, externalId); + } + if (subs.isEmpty() && parking != null) { + subs = subRepo.findByParkingAndActiveIsTrue(parking); + } + + if (subs.isEmpty()) { + log.info("[SOONOUT] no subscribers (provider={}, externalId={}, parkingId={})", + provider, externalId, parking != null ? parking.getId() : null); + return so.getId(); + } + log.info("[SOONOUT] subscribers found={}, provider={}, externalId={}, parkingId={}", + subs.size(), provider, externalId, parking != null ? parking.getId() : null); + + // 위치 로드 + var memberIds = subs.stream().map(s -> s.getMember().getId()).collect(Collectors.toSet()); + + + var locs = locationRepo.findByMemberIdIn(memberIds).stream() + .collect(Collectors.toMap(l -> l.getMember().getId(), l -> l)); + + + + var now = OffsetDateTime.now(ZoneId.of("Asia/Seoul")); + int sentCnt = 0; + int skippedSelf=0, skippedExpired = 0, skippedMin = 0, skippedNoLoc = 0, skippedOldLoc = 0, skippedDistance = 0, skippedDup = 0, skippedNoEmail = 0; + + for (AlertSubscription s : subs) { + Long memId = s.getMember() != null ? s.getMember().getId() : null; + + //본인 제외 + if (so.getReservation() != null && so.getReservation().getMember() != null + && Objects.equals(memId, so.getReservation().getMember().getId())) { + skippedSelf++; continue; + } + + //구독 만료시간 + if (s.getExpiresAt() != null && s.getExpiresAt().isBefore(now)) { + skippedExpired++; + + continue; + } + if (s.getMinMinutes() != null && minute < s.getMinMinutes()) { + skippedMin++; + + continue; + } + + var loc = locs.get(memId); + if (loc == null) { + skippedNoLoc++; + continue; + } + + // 주의: BaseEntity.updatedAt 타입과 now 타입이 다르면 Duration 계산에서 문제가 될 수 있음 (현 로직 유지) + try { + var age = Duration.between(loc.getUpdatedAt(), now).abs(); + if (age.compareTo(LOCATION_TTL) > 0) { + skippedOldLoc++; + continue; + } + } catch (Exception e) { + log.warn("[SOONOUT_AlERT] TTL check failed for memberId={}, err={}", memId, e.toString()); + // 계산 실패 시 그냥 통과시키고 아래 거리 체크 진행 + } + + double dist = haversineMeters(lat, lng, loc.getLat(), loc.getLng()); + boolean shouldSend = false; + if (dist <= KM_1) { + //거리가 1km 이내일시 + shouldSend = (minute == 10 || minute == 5); + } else if (dist <= KM_2) { + //거리가 2km 이내일시 + shouldSend = (minute == 10); + } + + log.debug("[SOONOUT_AlERT] memId={}, dist={}m, minute={}, shouldSend={}", + memId, Math.round(dist), minute, shouldSend); + + //거리애 구독자가 없다면 + if (!shouldSend) { + skippedDistance++; + continue; + } + + boolean dup = eventRepo.existsByTypeAndSoonOutIdAndMemberId(NotificationType.SOONOUT, so.getId(), memId); + if (dup) { + skippedDup++; + + continue; + } + + //이메일 가져오기 + String email = s.getMember().getMemberId(); // 현 로직 유지(이 값이 이메일이 아닐 가능성 높음) + + //이메일이 없을시 + if (email == null || email.isBlank()) { + skippedNoEmail++; + + continue; + } + + //장소이름설정 개인 / 민영,공영 + String placeName = (parking != null && parking.getName() != null) + ? parking.getName() + : placeNameOptional; + + try { + notifier.sendSoonOutEmail(email, placeName, minute,address); + eventRepo.save(NotificationEvent.builder() + .type(NotificationType.SOONOUT) + .soonOutId(so.getId()) + .member(s.getMember()) + .sent(true) + .build()); + sentCnt++; + + } catch (Exception e) { + log.error("[SOONOUT_AlERT] send/save failed: soId={}, memberId={}, email={}, err={}", + so.getId(), memId, email, e.getMessage(), e); + } + } + + log.info("[SOONOUT_AlERT] soId={} sent={} skipped: expired={} min={} nolocation={} oldloc={} distance={} dup={} self={} noemail={}", + so.getId(), sentCnt, skippedExpired, skippedMin, skippedNoLoc, skippedOldLoc, + skippedDistance, skippedDup, skippedSelf, skippedNoEmail); + + return so.getId(); + } + + private static double haversineMeters(double lat1, double lon1, double lat2, double lon2) { + final double R = 6371000.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 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + } //곧 알림 추가 // @Override @@ -67,4 +276,8 @@ public class SoonOutServiceImpl implements SoonOutService { // soonOut.setStatus(false); // return new CancelSoonOutResponseDto(soonOut.getId(), soonOut.getStatus()); // } + + + + } diff --git a/src/main/java/com/api/saojeong/alert/controller/AlertController.java b/src/main/java/com/api/saojeong/alert/controller/AlertController.java index c942ef3..c0fba29 100644 --- a/src/main/java/com/api/saojeong/alert/controller/AlertController.java +++ b/src/main/java/com/api/saojeong/alert/controller/AlertController.java @@ -11,12 +11,15 @@ import com.api.saojeong.global.security.LoginMember; import com.api.saojeong.global.utill.response.CustomApiResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.*; +import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.*; import java.time.OffsetDateTime; +@Slf4j @RestController @RequestMapping("/api/alerts") @RequiredArgsConstructor @@ -34,7 +37,20 @@ public ResponseEntity subscribe(@LoginMember Member memberId, @RequestParam(required = false) Integer minMinutes, @RequestParam(required = false) @DateTimeFormat(iso=DateTimeFormat.ISO.DATE_TIME) OffsetDateTime expiresAt, - @RequestParam(defaultValue = "true") boolean active) { + @RequestParam(defaultValue = "true") boolean active, + @RequestParam MultiValueMap raw ) { + + log.info("[ALERT-SUBSCRIBE][RAW] {}", raw); + boolean providerMode = provider != null && !provider.isBlank(); + boolean parkingMode = parkingId != null; + + if (providerMode == parkingMode) { + return ResponseEntity.badRequest().body("parkingId 또는 provider 중 하나만 보내주세요."); + } + if (providerMode && (externalId == null || externalId.isBlank())) { + return ResponseEntity.badRequest().body("provider가 있을 땐 externalId가 필수입니다."); + } + Member m = memberRepo.findById(memberId.getId()).orElseThrow(); Parking p = (parkingId != null) ? parkingRepo.findById(parkingId).orElse(null) : null; Long id = alertService.subscribe(m, p, provider, externalId, minMinutes, expiresAt, active); diff --git a/src/main/java/com/api/saojeong/alert/repository/NotificationEventRepository.java b/src/main/java/com/api/saojeong/alert/repository/NotificationEventRepository.java index 3f403e4..f92da78 100644 --- a/src/main/java/com/api/saojeong/alert/repository/NotificationEventRepository.java +++ b/src/main/java/com/api/saojeong/alert/repository/NotificationEventRepository.java @@ -10,4 +10,5 @@ public interface NotificationEventRepository extends JpaRepository findBySoonOutIdAndMemberId(Long soonOutId, Long memberId); boolean existsByTypeAndReservationId(NotificationType type, Long reservationId); + boolean existsByTypeAndSoonOutIdAndMemberId(NotificationType notificationType, Long id, Long memId); } \ No newline at end of file diff --git a/src/main/java/com/api/saojeong/alert/service/AlertService.java b/src/main/java/com/api/saojeong/alert/service/AlertService.java index 0ff02e9..3a5b7b4 100644 --- a/src/main/java/com/api/saojeong/alert/service/AlertService.java +++ b/src/main/java/com/api/saojeong/alert/service/AlertService.java @@ -1,6 +1,7 @@ package com.api.saojeong.alert.service; import com.api.saojeong.SoonOut.respotiory.SoonOutRepository; +import com.api.saojeong.alert.Enum.NotificationType; import com.api.saojeong.alert.repository.AlertSubscriptionRepository; import com.api.saojeong.alert.repository.NotificationEventRepository; import com.api.saojeong.domain.*; @@ -21,15 +22,8 @@ @RequiredArgsConstructor public class AlertService { - private static final double KM_1 = 1000.0; - private static final double KM_2 = 2000.0; - private static final Duration LOCATION_TTL = Duration.ofMinutes(15); - private final AlertSubscriptionRepository subRepo; - private final NotificationEventRepository eventRepo; - private final SoonOutRepository soonRepo; - private final MemberLocationRepository locationRepo; - private final NotificationService notifier; + @Transactional public Long subscribe(Member member, Parking parking, @@ -54,163 +48,5 @@ public Long subscribe(Member member, Parking parking, return id; } - @Transactional - public Long createSoonOut(Double lat, Double lng, int minute, boolean status, - Parking parking, String provider, String externalId, Reservation reservation, - String placeNameOptional,String address) { - - log.info("[SOONOUT][REQ] lat={}, lng={}, minute={}, status={}, parkingId={}, provider={}, externalId={}, reservationId={}, placeName={}", - lat, lng, minute, status, - parking != null ? parking.getId() : null, - provider, externalId, - reservation != null ? reservation.getId() : null, - placeNameOptional); - - SoonOut so ; - //개인이면 - if(parking !=null){ - so = SoonOut.builder() - .lat(lat) - .lng(lng) - .minute(minute) - .status(status) - .parking(parking) - .reservation(reservation) - .placeName(placeNameOptional) - .address(address) - .build(); - } - else { //공영,민영 - - so = SoonOut.builder() - .lat(lat).lng(lng) - .minute(minute) - .status(status) - .provider(provider) - .externalId(externalId) - .reservation(reservation) - .placeName(placeNameOptional) - .build(); - } - so = soonRepo.save(so); - - - - // 대상 구독자 (현재 로직 유지) - List subs = subRepo.findByProviderAndExternalIdAndActiveIsTrue(provider, externalId); - - if (subs.isEmpty()) { - List subs1 = subRepo.findByParkingAndActiveIsTrue(parking); - - if (subs.isEmpty()) { - - return so.getId(); - } - } - - // 위치 로드 - var memberIds = subs.stream().map(s -> s.getMember().getId()).collect(Collectors.toSet()); - - - var locs = locationRepo.findByMemberIdIn(memberIds).stream() - .collect(Collectors.toMap(l -> l.getMember().getId(), l -> l)); - - - - var now = OffsetDateTime.now(ZoneId.of("Asia/Seoul")); - int sentCnt = 0; - int skippedExpired = 0, skippedMin = 0, skippedNoLoc = 0, skippedOldLoc = 0, skippedDistance = 0, skippedDup = 0, skippedNoEmail = 0; - - for (AlertSubscription s : subs) { - Long memId = s.getMember() != null ? s.getMember().getId() : null; - - if (s.getExpiresAt() != null && s.getExpiresAt().isBefore(now)) { - skippedExpired++; - continue; - } - if (s.getMinMinutes() != null && minute < s.getMinMinutes()) { - skippedMin++; - - continue; - } - - var loc = locs.get(memId); - if (loc == null) { - skippedNoLoc++; - continue; - } - - // 주의: BaseEntity.updatedAt 타입과 now 타입이 다르면 Duration 계산에서 문제가 될 수 있음 (현 로직 유지) - try { - var age = Duration.between(loc.getUpdatedAt(), now).abs(); - if (age.compareTo(LOCATION_TTL) > 0) { - skippedOldLoc++; - continue; - } - } catch (Exception e) { - - // 계산 실패 시 그냥 통과시키고 아래 거리 체크 진행 - } - - double dist = haversineMeters(lat, lng, loc.getLat(), loc.getLng()); - boolean shouldSend = false; - if (dist <= KM_1) { - shouldSend = (minute == 10 || minute == 5); - } else if (dist <= KM_2) { - shouldSend = (minute == 10); - } - - if (!shouldSend) { - skippedDistance++; - continue; - } - - var exists = eventRepo.findBySoonOutIdAndMemberId(so.getId(), memId); - if (exists.isPresent()) { - skippedDup++; - - continue; - } - - String email = s.getMember().getMemberId(); // 현 로직 유지(이 값이 이메일이 아닐 가능성 높음) - - - if (email == null || email.isBlank()) { - skippedNoEmail++; - - continue; - } - - String placeName = (parking != null && parking.getName() != null) - ? parking.getName() - : placeNameOptional; - - try { - notifier.sendSoonOutEmail(email, placeName, minute,address); - eventRepo.save(NotificationEvent.builder() - .soonOutId(so.getId()) - .member(s.getMember()) - .sent(true) - .build()); - sentCnt++; - - } catch (Exception e) { - - } - } - - - return so.getId(); - } - - private static double haversineMeters(double lat1, double lon1, double lat2, double lon2) { - final double R = 6371000.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 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); - } } diff --git a/src/main/java/com/api/saojeong/domain/SoonOut.java b/src/main/java/com/api/saojeong/domain/SoonOut.java index 3e7d840..272574d 100644 --- a/src/main/java/com/api/saojeong/domain/SoonOut.java +++ b/src/main/java/com/api/saojeong/domain/SoonOut.java @@ -29,7 +29,8 @@ public class SoonOut extends BaseEntity { @Column(name="address") private String address; - // 곧나감 남김(활성) 여부 + + // 곧나감 남김(활성) 여부 (버튼 누름 여부) @Column(name = "status", nullable = false) private Boolean status;