Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
@RequiredArgsConstructor
public class SoonOutApiController {

private final AlertService alertService;
private final SoonOutService soonOutService;
private final ParkingRepository parkingRepo;
private final ReservationRepository reservationRepo;

Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ public interface SoonOutRepository extends JpaRepository<SoonOut,Long> {
AND TIMESTAMPADD(MINUTE, minute, created_at) <= :now
""", nativeQuery = true)
int deleteExpiredNow(@Param("now") LocalDateTime now);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
//곧 나감 알림 취소
Expand Down
227 changes: 220 additions & 7 deletions src/main/java/com/api/saojeong/SoonOut/service/SoonOutServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<AlertSubscription> 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
Expand Down Expand Up @@ -67,4 +276,8 @@ public class SoonOutServiceImpl implements SoonOutService {
// soonOut.setStatus(false);
// return new CancelSoonOutResponseDto(soonOut.getId(), soonOut.getStatus());
// }




}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String,String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ public interface NotificationEventRepository extends JpaRepository<NotificationE
Optional<NotificationEvent> findBySoonOutIdAndMemberId(Long soonOutId, Long memberId);
boolean existsByTypeAndReservationId(NotificationType type, Long reservationId);

boolean existsByTypeAndSoonOutIdAndMemberId(NotificationType notificationType, Long id, Long memId);
}
Loading