diff --git a/build.gradle b/build.gradle index bf983205..8cbd85ff 100644 --- a/build.gradle +++ b/build.gradle @@ -83,6 +83,10 @@ dependencies { // circuit breaker dependencies implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' + + // mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + } dependencyManagement { diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivitySummaryResponse.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivitySummaryResponse.java index bd741e59..ceaaf863 100644 --- a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivitySummaryResponse.java +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivitySummaryResponse.java @@ -6,6 +6,7 @@ @Builder public record ActivitySummaryResponse( String spotName, - TotalIndex totalIndex + TotalIndex totalIndex, + Long spotId ) { } diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityWeatherResponse.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityWeatherResponse.java index 8b598ec9..0429d478 100644 --- a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityWeatherResponse.java +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityWeatherResponse.java @@ -4,6 +4,7 @@ public record ActivityWeatherResponse( String location, String windSpeed, String waveHeight, - String waterTemp + String waterTemp, + Long spotId ) { } diff --git a/src/main/java/sevenstar/marineleisure/activity/service/ActivityService.java b/src/main/java/sevenstar/marineleisure/activity/service/ActivityService.java index bb03ad14..97bdc229 100644 --- a/src/main/java/sevenstar/marineleisure/activity/service/ActivityService.java +++ b/src/main/java/sevenstar/marineleisure/activity/service/ActivityService.java @@ -61,12 +61,16 @@ private Map getLocalActivitySummary(BigDecimal SpotPreviewReadResponse preview = spotService.preview(latitude.floatValue(), longitude.floatValue()); responses.put("Fishing", - new ActivitySummaryResponse(preview.fishing().getName(), preview.fishing().getTotalIndex())); + new ActivitySummaryResponse(preview.fishing().getName(), preview.fishing().getTotalIndex(),preview.fishing() + .getSpotId())); responses.put("Mudflat", - new ActivitySummaryResponse(preview.mudflat().getName(), preview.mudflat().getTotalIndex())); + new ActivitySummaryResponse(preview.mudflat().getName(), preview.mudflat().getTotalIndex(),preview.mudflat() + .getSpotId())); responses.put("Surfing", - new ActivitySummaryResponse(preview.surfing().getName(), preview.surfing().getTotalIndex())); - responses.put("Scuba", new ActivitySummaryResponse(preview.scuba().getName(), preview.scuba().getTotalIndex())); + new ActivitySummaryResponse(preview.surfing().getName(), preview.surfing().getTotalIndex(),preview.surfing() + .getSpotId())); + responses.put("Scuba", new ActivitySummaryResponse(preview.scuba().getName(), preview.scuba().getTotalIndex(),preview.scuba() + .getSpotId())); // Fishing fishingBySpot = null; // Mudflat mudflatBySpot = null; @@ -151,25 +155,25 @@ private Map getGlobalActivitySummary() { if (fishingResult.isPresent()) { Fishing fishing = fishingResult.get(); OutdoorSpot spot = outdoorSpotRepository.findById(fishing.getSpotId()).get(); - responses.put("Fishing", new ActivitySummaryResponse(spot.getName(), fishing.getTotalIndex())); + responses.put("Fishing", new ActivitySummaryResponse(spot.getName(), fishing.getTotalIndex(),fishing.getSpotId())); } if (mudflatResult.isPresent()) { Mudflat mudflat = mudflatResult.get(); OutdoorSpot spot = outdoorSpotRepository.findById(mudflat.getSpotId()).get(); - responses.put("Mudflat", new ActivitySummaryResponse(spot.getName(), mudflat.getTotalIndex())); + responses.put("Mudflat", new ActivitySummaryResponse(spot.getName(), mudflat.getTotalIndex(),mudflat.getSpotId())); } if (scubaResult.isPresent()) { Scuba scuba = scubaResult.get(); OutdoorSpot spot = outdoorSpotRepository.findById(scuba.getSpotId()).get(); - responses.put("Scuba", new ActivitySummaryResponse(spot.getName(), scuba.getTotalIndex())); + responses.put("Scuba", new ActivitySummaryResponse(spot.getName(), scuba.getTotalIndex(),scuba.getSpotId())); } if (surfingResult.isPresent()) { Surfing surfing = surfingResult.get(); OutdoorSpot spot = outdoorSpotRepository.findById(surfing.getSpotId()).get(); - responses.put("Surfing", new ActivitySummaryResponse(spot.getName(), surfing.getTotalIndex())); + responses.put("Surfing", new ActivitySummaryResponse(spot.getName(), surfing.getTotalIndex(),surfing.getSpotId())); } return responses; @@ -234,7 +238,8 @@ public ActivityWeatherResponse getWeatherBySpot(Float latitude, Float longitude) nearSpot.getName(), fishing.getWindSpeedMax().toString(), fishing.getWaveHeightMax().toString(), - fishing.getSeaTempMax().toString() + fishing.getSeaTempMax().toString(), + nearSpot.getId() ); } diff --git a/src/main/java/sevenstar/marineleisure/favorite/repository/FavoriteRepository.java b/src/main/java/sevenstar/marineleisure/favorite/repository/FavoriteRepository.java index 5b94e480..356b7f37 100644 --- a/src/main/java/sevenstar/marineleisure/favorite/repository/FavoriteRepository.java +++ b/src/main/java/sevenstar/marineleisure/favorite/repository/FavoriteRepository.java @@ -36,4 +36,12 @@ List findFavoritesByMemberIdAndCursorId( Pageable pageable ); boolean existsByMemberIdAndSpotId(Long memberId, Long spotId); + + @Query(value = """ + SELECT m.email + FROM FavoriteSpot fs + JOIN Member m ON fs.memberId = m.id + WHERE fs.spotId = :spotId + """) + List findEmailByFavoriteBestSpot(Long spotId); } diff --git a/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerService.java b/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerService.java index 7ab75df6..5f55ea83 100644 --- a/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerService.java +++ b/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerService.java @@ -12,6 +12,7 @@ import sevenstar.marineleisure.global.api.kakao.service.PresetSchedulerService; import sevenstar.marineleisure.global.api.khoa.service.KhoaApiService; import sevenstar.marineleisure.global.api.openmeteo.dto.service.OpenMeteoService; +import sevenstar.marineleisure.global.mail.MailService; import sevenstar.marineleisure.spot.repository.SpotViewQuartileRepository; @Service @@ -24,7 +25,7 @@ public class SchedulerService { private final PresetSchedulerService presetSchedulerService; private final SpotViewQuartileRepository spotViewQuartileRepository; - + private final MailService mailService; private final Executor taskExecutor; /** @@ -55,6 +56,12 @@ public void scheduler() { // 모든 병렬 작업이 완료될 때까지 기다림 CompletableFuture.allOf(openMeteoFuture, presetSchedulerFuture, spotViewQuartileFuture).join(); + try { + mailService.sendMailToHaveFavoriteBestSpot(today); + } catch (Exception e) { + log.error("Error sending mail to users with favorite best spots", e); + } + log.info("=== update data ==="); } } diff --git a/src/main/java/sevenstar/marineleisure/global/enums/ActivityCategory.java b/src/main/java/sevenstar/marineleisure/global/enums/ActivityCategory.java index 24431f12..d369f8f5 100644 --- a/src/main/java/sevenstar/marineleisure/global/enums/ActivityCategory.java +++ b/src/main/java/sevenstar/marineleisure/global/enums/ActivityCategory.java @@ -1,13 +1,21 @@ package sevenstar.marineleisure.global.enums; +import lombok.Getter; import sevenstar.marineleisure.global.exception.CustomException; import sevenstar.marineleisure.global.exception.enums.CommonErrorCode; +@Getter public enum ActivityCategory { - FISHING, - SURFING, - SCUBA, - MUDFLAT; + FISHING("낚시"), + SURFING("서핑"), + SCUBA("스쿠버다이빙"), + MUDFLAT("갯벌체험"); + + private String koreanName; + + ActivityCategory(String koreanName) { + this.koreanName = koreanName; + } public static ActivityCategory parse(String category) { try { diff --git a/src/main/java/sevenstar/marineleisure/global/mail/MailService.java b/src/main/java/sevenstar/marineleisure/global/mail/MailService.java new file mode 100644 index 00000000..b4d86c99 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/mail/MailService.java @@ -0,0 +1,142 @@ +package sevenstar.marineleisure.global.mail; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.favorite.repository.FavoriteRepository; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.spot.dto.EmailContent; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivityProvider; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MailService { + private static final String MESSAGE_SUBJECT = "[MarineLeisure] 즐겨찾기한 스팟이 최상의 컨디션이에요!"; + + private final JavaMailSender javaMailSender; + private final FavoriteRepository favoriteRepository; + private final List providers; + + public void sendMail(String to, String subject, String htmlContent) { + try { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "UTF-8"); + helper.setFrom("your_email@gmail.com"); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlContent, true); // true = HTML + + javaMailSender.send(mimeMessage); + } catch (Exception e) { + log.error("메일 전송 실패", e); + } + } + + + public void sendMailToHaveFavoriteBestSpot(LocalDate date) { + TotalIndex totalIndex = TotalIndex.VERY_GOOD; + List emailContents = new ArrayList<>(); + for (ActivityProvider provider : providers) { + emailContents.addAll(provider.findEmailContent(totalIndex, date)); + } + Map>> result = new HashMap<>(); + for (EmailContent emailContent : emailContents) { + List emails = favoriteRepository.findEmailByFavoriteBestSpot(emailContent.spotId()); + for (String email : emails) { + if (result.containsKey(email)) { + result.get(email).get(emailContent.category()).add(emailContent.spotName()); + } else { + Map> map = new EnumMap<>(ActivityCategory.class); + for (ActivityCategory value : ActivityCategory.values()) { + map.put(value, new HashSet<>()); + } + map.get(emailContent.category()).add(emailContent.spotName()); + result.put(email, map); + } + } + } + for (Map.Entry>> entry : result.entrySet()) { + sendMail(entry.getKey(), MESSAGE_SUBJECT, transformEmailContent(entry.getValue())); + } + } + + // private String transformEmailContent(Map> map) { + // StringBuilder sb = new StringBuilder(); + // sb.append("
"); + // sb.append("

안녕하세요, MarineLeisure입니다 🌊

"); + // sb.append("

고객님이 즐겨찾기한 장소 중, 오늘 같은 날 최상의 컨디션을 보이는 스팟들을 추천드립니다.

"); + // + // sb.append("
    "); + // for (ActivityCategory category : ActivityCategory.values()) { + // Set spots = map.getOrDefault(category, Set.of()); + // String spotList = spots.isEmpty() ? "없어요 😢" : String.join(", ", spots); + // sb.append("
  • ") + // .append(category.getKoreanName()) + // .append("에 좋은 스팟: ") + // .append(spotList) + // .append("
  • "); + // } + // sb.append("
"); + // + // sb.append("

👉 MarineLeisure 앱에서 자세히 보기

"); + // sb.append("

안전하고 즐거운 하루 보내세요 😊
MarineLeisure 드림

"); + // sb.append("
"); + // + // return sb.toString(); + // } + + private String transformEmailContent(Map> map) { + StringBuilder sb = new StringBuilder(); + + sb.append("
") + .append("
") + + .append("

🌊 MarineLeisure 추천 스팟 알림

") + .append("

") + .append("고객님이 즐겨찾기한 해양 활동 스팟 중, 오늘 같은 날 최고의 컨디션을 보이는 장소를 추천드릴게요!") + .append("

"); + + for (ActivityCategory category : ActivityCategory.values()) { + Set spots = map.getOrDefault(category, Set.of()); + if (!spots.isEmpty()) { + sb.append("
") + .append("

") + .append("✔️ ").append(category.getKoreanName()).append(" 추천 스팟") + .append("

") + .append("
    "); + for (String spot : spots) { + sb.append("
  • ").append(spot).append("
  • "); + } + sb.append("
"); + } + } + + sb.append("
") + .append("MarineLeisure 앱에서 확인하기") + .append("
") + + .append("

") + .append("안전하고 즐거운 하루 보내세요!
MarineLeisure 드림") + .append("

") + + .append("
"); + + return sb.toString(); + } + +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/EmailContent.java b/src/main/java/sevenstar/marineleisure/spot/dto/EmailContent.java new file mode 100644 index 00000000..c1d49c98 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/EmailContent.java @@ -0,0 +1,10 @@ +package sevenstar.marineleisure.spot.dto; + +import sevenstar.marineleisure.global.enums.ActivityCategory; + +public record EmailContent( + Long spotId, + String spotName, + ActivityCategory category +) { +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityProvider.java index b06205e8..0792e01f 100644 --- a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityProvider.java +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityProvider.java @@ -18,8 +18,10 @@ import sevenstar.marineleisure.global.api.openmeteo.dto.item.UvIndexItem; import sevenstar.marineleisure.global.enums.ActivityCategory; import sevenstar.marineleisure.global.enums.FishingType; +import sevenstar.marineleisure.global.enums.TotalIndex; import sevenstar.marineleisure.global.utils.GeoUtils; import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.dto.EmailContent; import sevenstar.marineleisure.spot.repository.ActivityRepository; import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; @@ -43,6 +45,8 @@ public abstract class ActivityProvider { public abstract void update(LocalDate startDate, LocalDate endDate); + public abstract List findEmailContent(TotalIndex totalIndex, LocalDate forecastDate); + @Transactional protected OutdoorSpot createOutdoorSpot(KhoaItem item, FishingType fishingType) { return outdoorSpotRepository.findByLatitudeAndLongitudeAndCategory(item.getLatitude(), item.getLongitude(), diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/FishingProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/FishingProvider.java index 638328bc..71911dbf 100644 --- a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/FishingProvider.java +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/FishingProvider.java @@ -23,6 +23,7 @@ import sevenstar.marineleisure.global.enums.TotalIndex; import sevenstar.marineleisure.global.utils.DateUtils; import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.dto.EmailContent; import sevenstar.marineleisure.spot.dto.projection.FishingReadProjection; import sevenstar.marineleisure.spot.mapper.SpotDetailMapper; import sevenstar.marineleisure.spot.repository.ActivityRepository; @@ -94,6 +95,11 @@ public void update(LocalDate startDate, LocalDate endDate) { } } + @Override + public List findEmailContent(TotalIndex totalIndex, LocalDate forecastDate) { + return fishingRepository.findEmailContentByTotalIndexAndForecastDate(totalIndex, forecastDate); + } + private List transform(List fishingForecasts) { List details = new ArrayList<>(); for (FishingReadProjection fishingForecast : fishingForecasts) { diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/MudflatProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/MudflatProvider.java index 6b67783a..8810dd33 100644 --- a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/MudflatProvider.java +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/MudflatProvider.java @@ -19,6 +19,7 @@ import sevenstar.marineleisure.global.enums.TotalIndex; import sevenstar.marineleisure.global.utils.DateUtils; import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.dto.EmailContent; import sevenstar.marineleisure.spot.mapper.SpotDetailMapper; import sevenstar.marineleisure.spot.repository.ActivityRepository; @@ -73,6 +74,11 @@ public void update(LocalDate startDate, LocalDate endDate) { } } + @Override + public List findEmailContent(TotalIndex totalIndex, LocalDate forecastDate) { + return mudflatRepository.findEmailContentByTotalIndexAndForecastDate(totalIndex, forecastDate); + } + private List transform(List mudflatForecasts) { List details = new ArrayList<>(); for (Mudflat mudflatForecast : mudflatForecasts) { diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ScubaProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ScubaProvider.java index 51464a42..182a973c 100644 --- a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ScubaProvider.java +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ScubaProvider.java @@ -21,6 +21,7 @@ import sevenstar.marineleisure.global.enums.TotalIndex; import sevenstar.marineleisure.global.utils.DateUtils; import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.dto.EmailContent; import sevenstar.marineleisure.spot.mapper.SpotDetailMapper; import sevenstar.marineleisure.spot.repository.ActivityRepository; @@ -76,6 +77,11 @@ public void update(LocalDate startDate, LocalDate endDate) { } } + @Override + public List findEmailContent(TotalIndex totalIndex, LocalDate forecastDate) { + return scubaRepository.findEmailContentByTotalIndexAndForecastDate(totalIndex, forecastDate); + } + private List transform(List scubaForecasts) { List details = new ArrayList<>(); for (Scuba scubaForecast : scubaForecasts) { diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/SurfingProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/SurfingProvider.java index ff540612..1ea22c90 100644 --- a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/SurfingProvider.java +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/SurfingProvider.java @@ -19,6 +19,7 @@ import sevenstar.marineleisure.global.enums.TotalIndex; import sevenstar.marineleisure.global.utils.DateUtils; import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.dto.EmailContent; import sevenstar.marineleisure.spot.mapper.SpotDetailMapper; import sevenstar.marineleisure.spot.repository.ActivityRepository; @@ -72,6 +73,11 @@ public void update(LocalDate startDate, LocalDate endDate) { } } + @Override + public List findEmailContent(TotalIndex totalIndex, LocalDate forecastDate) { + return surfingRepository.findEmailContentByTotalIndexAndForecastDate(totalIndex, forecastDate); + } + private List transform(List surfingForecasts) { List details = new ArrayList<>(); for (Surfing surfingForecast : surfingForecasts) { diff --git a/src/main/java/sevenstar/marineleisure/spot/repository/ActivityRepository.java b/src/main/java/sevenstar/marineleisure/spot/repository/ActivityRepository.java index 76d1d953..57a18d9c 100644 --- a/src/main/java/sevenstar/marineleisure/spot/repository/ActivityRepository.java +++ b/src/main/java/sevenstar/marineleisure/spot/repository/ActivityRepository.java @@ -11,6 +11,7 @@ import org.springframework.data.repository.query.Param; import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.spot.dto.EmailContent; @NoRepositoryBean public interface ActivityRepository extends JpaRepository { @@ -29,4 +30,13 @@ public interface ActivityRepository extends JpaRepository { """) List findForecasts(@Param("spotId") Long spotId, @Param("date") LocalDate date); + @Query(value = """ + SELECT new sevenstar.marineleisure.spot.dto.EmailContent(o.id,o.name,o.category) + FROM OutdoorSpot o + JOIN #{#entityName} e ON o.id=e.spotId + WHERE e.totalIndex = :totalIndex + AND e.forecastDate = :forecastDate + """) + List findEmailContentByTotalIndexAndForecastDate(TotalIndex totalIndex, LocalDate forecastDate); + } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 4c350077..0c48111d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -26,7 +26,7 @@ spring: show_sql: true dialect: org.hibernate.dialect.MySQL8Dialect hibernate: - ddl-auto: none + ddl-auto: update defer-datasource-initialization: true ai: @@ -36,10 +36,20 @@ spring: model: gpt-3.5-turbo flyway: - enabled: true + enabled: false baseline-on-migrate: true # locations: classpath:db/migration - + mail: + host: smtp.gmail.com # SMTP 서버 호스트 + port: 587 # SMTP 서버 포트 + username: gwj16301 + password: ${EMAIL_PASSWORD} # SMTP 서버 비밀번호 + properties: + mail: + smtp: + auth: true # 사용자 인증 시도 여부 + starttls: + enable: true # starttls 활성화 여부 api: # 국립해양조사원(Korea Hydrographic and Oceanographic Agency, KHOA) khoa: