From a82ba91de6cc4abc82e7a977beb6a2a9cd127a72 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Thu, 17 Jul 2025 09:32:06 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=20=EA=B8=B0?= =?UTF-8?q?=ED=9A=8D=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?ENUM=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20Entity=20=EC=96=91=EB=B0=A9=ED=96=A5=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/entity/TemplateKeyword.java | 8 ++--- .../weather/entity/WeatherTemplate.java | 14 +++++--- .../weather/entity/enums/PrecipCategory.java | 33 +++++++++++++++++-- .../weather/entity/enums/TempCategory.java | 8 ++--- .../domain/weather/entity/enums/Weather.java | 7 ---- .../weather/entity/enums/WeatherType.java | 10 ++++++ src/main/resources/application.yml | 15 +++++++++ 7 files changed, 72 insertions(+), 23 deletions(-) delete mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/Weather.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/WeatherType.java diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/TemplateKeyword.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/TemplateKeyword.java index 79726cc..9a848f3 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/TemplateKeyword.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/TemplateKeyword.java @@ -17,10 +17,10 @@ public class TemplateKeyword { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "weather_id") - private Keyword weather; + @JoinColumn(name = "weather_template_id", nullable = false) + private WeatherTemplate weatherTemplate; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "weather_template_id") - private WeatherTemplate weatherTemplate; + @JoinColumn(name = "keyword_id", nullable = false) + private Keyword keyword; } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/WeatherTemplate.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/WeatherTemplate.java index c6bf70b..50b8303 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/WeatherTemplate.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/WeatherTemplate.java @@ -5,9 +5,12 @@ import lombok.*; import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; -import org.withtime.be.withtimebe.domain.weather.entity.enums.Weather; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; import org.withtime.be.withtimebe.global.common.BaseEntity; +import java.util.ArrayList; +import java.util.List; + @Entity @Getter @Builder @@ -23,7 +26,7 @@ public class WeatherTemplate extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "weather", nullable = false) - private Weather weather; + private WeatherType weatherType; @Enumerated(EnumType.STRING) @Column(name = "temp_category", nullable = false) @@ -36,9 +39,10 @@ public class WeatherTemplate extends BaseEntity { @Column(name = "message") private String message; - @Column(name = "keywords") - private String keywords; - @Column(name = "emoji") private String emoji; + + @OneToMany(mappedBy = "weatherTemplate", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List templateKeywords = new ArrayList<>(); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/PrecipCategory.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/PrecipCategory.java index d2992ef..dc776a2 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/PrecipCategory.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/PrecipCategory.java @@ -1,7 +1,34 @@ package org.withtime.be.withtimebe.domain.weather.entity.enums; public enum PrecipCategory { - 없음, - 약간_비옴, - 강수_많음 + + /** + * 비 없음 (0%) + * 맑거나 흐림 + */ + NONE, + + /** + * 비 거의 없음 (1~30%) + * 가볍게 산책 가능, 실외 일정 유지 가능 + */ + VERY_LOW, + + /** + * 비 약간 가능성 (31~60%) + * 우산 필요 가능성 있음, 유연한 동선 필요 + */ + LOW, + + /** + * 비 올 가능성 높음 (61~90%) + * 실외 지양, 실내 위주 일정 추천 + */ + HIGH, + + /** + * 비 확실 (91~100%) + * 실외 활동 지양, 완전 실내형 코스 구성 + */ + VERY_HIGH } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/TempCategory.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/TempCategory.java index 419987e..78e4248 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/TempCategory.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/TempCategory.java @@ -1,8 +1,8 @@ package org.withtime.be.withtimebe.domain.weather.entity.enums; public enum TempCategory { - 쌀쌀함, - 선선함, - 적당함, - 무더움 + CHILLY, // 쌀쌀한 날씨 (≤10℃) + COOL, // 선선한 날씨 (11~20℃) + MILD, // 무난한 날씨 (21~25℃) + HOT // 무더운 날씨 (≥26℃) } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/Weather.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/Weather.java deleted file mode 100644 index 55ccc9e..0000000 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/Weather.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.withtime.be.withtimebe.domain.weather.entity.enums; - -public enum Weather { - 맑음, - 흐림, - 눈 -} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/WeatherType.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/WeatherType.java new file mode 100644 index 0000000..6e69797 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/WeatherType.java @@ -0,0 +1,10 @@ +package org.withtime.be.withtimebe.domain.weather.entity.enums; + +public enum WeatherType { + CLEAR, // 맑음 + CLOUDY, // 구름 많음, 흐림 + RAINY, // 비 (구름 많고 비, 흐리고 비) + SNOWY, // 눈 (구름 많고 눈, 흐리고 눈) + RAIN_SNOW, // 비/눈 (흐리고 비/눈, 구름 많고 비/눈) + SHOWER // 소나기 (흐리고 소나기, 구름 많고 소나기) +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 91d21de..17a61d0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,6 +11,21 @@ spring: show-sql: true hibernate: ddl-auto: update + data: + redis: + host: localhost + port: 6379 + mail: + host: ${MAIL_SENDER_HOST} + port: 587 + username: ${MAIL_SENDER_USERNAME} + password: ${MAIL_SENDER_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true jwt: secret: ${JWT_SECRET} From 2b53a2517a447a0e90ab6f9d94ded624fc7e94f6 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Thu, 17 Jul 2025 13:05:34 +0900 Subject: [PATCH 02/12] =?UTF-8?q?=E2=9C=A8feat:=20Application=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?Keyword,=20Template=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=BD?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../initializer/WeatherDataInitializer.java | 140 ++++++++++++++++++ .../weather/repository/KeywordRepository.java | 8 + .../repository/TemplateKeywordRepository.java | 9 ++ .../repository/WeatherTemplateRepository.java | 7 + 4 files changed, 164 insertions(+) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/repository/KeywordRepository.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/repository/TemplateKeywordRepository.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java new file mode 100644 index 0000000..9c47d39 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java @@ -0,0 +1,140 @@ +package org.withtime.be.withtimebe.domain.weather.data.initializer; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.weather.entity.Keyword; +import org.withtime.be.withtimebe.domain.weather.entity.TemplateKeyword; +import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; +import org.withtime.be.withtimebe.domain.weather.repository.KeywordRepository; +import org.withtime.be.withtimebe.domain.weather.repository.TemplateKeywordRepository; +import org.withtime.be.withtimebe.domain.weather.repository.WeatherTemplateRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeatherDataInitializer { + + private final KeywordRepository keywordRepository; + private final WeatherTemplateRepository weatherTemplateRepository; + private final TemplateKeywordRepository templateKeywordRepository; + + @PostConstruct + @Transactional + public void init() { + initKeywords(); + initWeatherTemplates(); + initTemplateKeywords(); + } + + private void initKeywords() { + List keywordNames = List.of( + "활발한 활동", "전망 좋은 곳", "활기찬", + "감성적인", "탐험 중심", "쇼핑+데이트 복합", + "느긋하게 쉬기", "사진중심", "전시 공간", "북카페/책방" + ); + + List existing = keywordRepository.findAll().stream() + .map(Keyword::getName) + .toList(); + + List newKeywords = keywordNames.stream() + .filter(name -> !existing.contains(name)) + .map(name -> Keyword.builder().name(name).build()) + .toList(); + + keywordRepository.saveAll(newKeywords); + log.info("[ WeatherDataInitializer ] 키워드 데이터 초기화 완료 (새로 저장된 키워드 수: {})", newKeywords.size()); + } + + private void initWeatherTemplates() { + if (weatherTemplateRepository.count() > 0) { + log.info("[ WeatherDataInitializer ] 템플릿이 이미 존재합니다. 초기화 생략"); + return; + } + + List templates = List.of( + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 쌀쌀한 날씨가 예정되어 있습니다...").emoji("sunny").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 선선한 날씨가 예정되어 있습니다...").emoji("sunny").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 무난한 날씨가 예정되어 있습니다...").emoji("sunny").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 무더운 날씨가 예정되어 있습니다...").emoji("sunny").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 쌀쌀한 날씨가 예정되어 있습니다...").emoji("cloudy").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 선선한 날씨가 예정되어 있습니다...").emoji("cloudy").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 무난한 날씨가 예정되어 있습니다...").emoji("cloudy").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 무더운 날씨가 예정되어 있습니다...").emoji("cloudy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("비와 함께 쌀쌀한 날씨가 예정되어 있습니다...").emoji("rainy").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("비와 함께 선선한 날씨가 예정되어 있습니다...").emoji("rainy").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.HIGH).message("비와 함께 무난한 날씨가 예정되어 있습니다...").emoji("rainy").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.HIGH).message("비와 함께 무더운 날씨가 예정되어 있습니다...").emoji("rainy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("눈과 함께 쌀쌀한 날씨가 예정되어 있습니다...").emoji("snowy").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("눈과 함께 선선한 날씨가 예정되어 있습니다...").emoji("snowy").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("눈과 함께 무난한 날씨가 예정되어 있습니다...").emoji("snowy").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("눈과 함께 무더운 날씨가 예정되어 있습니다...").emoji("snowy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 쌀쌀한 날씨가 예정되어 있습니다...").emoji("rainy").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 선선한 날씨가 예정되어 있습니다...").emoji("rainy").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 무난한 날씨가 예정되어 있습니다...").emoji("rainy").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 무더운 날씨가 예정되어 있습니다...").emoji("rainy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 쌀쌀한 날씨가 예정되어 있습니다...").emoji("shower").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 선선한 날씨가 예정되어 있습니다...").emoji("shower").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 무난한 날씨가 예정되어 있습니다...").emoji("shower").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 무더운 날씨가 예정되어 있습니다...").emoji("shower").build() + ); + + weatherTemplateRepository.saveAll(templates); + log.info("[ WeatherDataInitializer ] 템플릿 {}개 저장 완료", templates.size()); + } + + private void initTemplateKeywords() { + List templates = weatherTemplateRepository.findAll(); + List keywords = keywordRepository.findAll(); + List newMappings = new ArrayList<>(); + + Set existingMappings = templateKeywordRepository.findAll().stream() + .map(tk -> tk.getWeatherTemplate().getId() + "-" + tk.getKeyword().getId()) + .collect(Collectors.toSet()); + + for (WeatherTemplate template : templates) { + for (Keyword keyword : keywords) { + if (!shouldMap(template.getWeatherType(), keyword.getName())) continue; + + String key = template.getId() + "-" + keyword.getId(); + + if (existingMappings.contains(key)) continue; + + newMappings.add(TemplateKeyword.builder() + .weatherTemplate(template) + .keyword(keyword) + .build()); + } + } + + templateKeywordRepository.saveAll(newMappings); + log.info("[ WeatherDataInitializer ] 템플릿-키워드 매핑 완료 (새로 추가된 매핑 수: {})", newMappings.size()); + } + + private boolean shouldMap(WeatherType weather, String keyword) { + return switch (weather) { + case CLEAR -> List.of("활발한 활동", "전망 좋은 곳", "활기찬").contains(keyword); + case CLOUDY -> List.of("감성적인", "탐험 중심", "쇼핑+데이트 복합").contains(keyword); + case RAINY -> List.of("감성적인", "쇼핑+데이트 복합", "느긋하게 쉬기").contains(keyword); + case SNOWY -> List.of("감성적인", "전망 좋은 곳", "사진중심").contains(keyword); + case RAIN_SNOW -> List.of("감성적인", "사진중심", "전시 공간").contains(keyword); + case SHOWER -> List.of("사진중심", "북카페/책방", "느긋하게 쉬기").contains(keyword); + }; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/KeywordRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/KeywordRepository.java new file mode 100644 index 0000000..a61e7d3 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/KeywordRepository.java @@ -0,0 +1,8 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.weather.entity.Keyword; + +public interface KeywordRepository extends JpaRepository { + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/TemplateKeywordRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/TemplateKeywordRepository.java new file mode 100644 index 0000000..90ecd68 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/TemplateKeywordRepository.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.weather.entity.TemplateKeyword; + +public interface TemplateKeywordRepository extends JpaRepository { + +} + diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java new file mode 100644 index 0000000..bca7720 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java @@ -0,0 +1,7 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate; + +public interface WeatherTemplateRepository extends JpaRepository { +} From 4fc63618a65b90ee201ccaa7822afb8038c3c34c Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Fri, 18 Jul 2025 10:39:30 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=20Application?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=20=EC=8B=9C=20=EC=82=BD=EC=9E=85=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../initializer/WeatherDataInitializer.java | 102 +++++++++++++----- 1 file changed, 73 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java index 9c47d39..44b7cb1 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java @@ -64,35 +64,79 @@ private void initWeatherTemplates() { } List templates = List.of( - WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 쌀쌀한 날씨가 예정되어 있습니다...").emoji("sunny").build(), - WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 선선한 날씨가 예정되어 있습니다...").emoji("sunny").build(), - WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 무난한 날씨가 예정되어 있습니다...").emoji("sunny").build(), - WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 무더운 날씨가 예정되어 있습니다...").emoji("sunny").build(), - - WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 쌀쌀한 날씨가 예정되어 있습니다...").emoji("cloudy").build(), - WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 선선한 날씨가 예정되어 있습니다...").emoji("cloudy").build(), - WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 무난한 날씨가 예정되어 있습니다...").emoji("cloudy").build(), - WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 무더운 날씨가 예정되어 있습니다...").emoji("cloudy").build(), - - WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("비와 함께 쌀쌀한 날씨가 예정되어 있습니다...").emoji("rainy").build(), - WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("비와 함께 선선한 날씨가 예정되어 있습니다...").emoji("rainy").build(), - WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.HIGH).message("비와 함께 무난한 날씨가 예정되어 있습니다...").emoji("rainy").build(), - WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.HIGH).message("비와 함께 무더운 날씨가 예정되어 있습니다...").emoji("rainy").build(), - - WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("눈과 함께 쌀쌀한 날씨가 예정되어 있습니다...").emoji("snowy").build(), - WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("눈과 함께 선선한 날씨가 예정되어 있습니다...").emoji("snowy").build(), - WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("눈과 함께 무난한 날씨가 예정되어 있습니다...").emoji("snowy").build(), - WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("눈과 함께 무더운 날씨가 예정되어 있습니다...").emoji("snowy").build(), - - WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 쌀쌀한 날씨가 예정되어 있습니다...").emoji("rainy").build(), - WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 선선한 날씨가 예정되어 있습니다...").emoji("rainy").build(), - WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 무난한 날씨가 예정되어 있습니다...").emoji("rainy").build(), - WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 무더운 날씨가 예정되어 있습니다...").emoji("rainy").build(), - - WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 쌀쌀한 날씨가 예정되어 있습니다...").emoji("shower").build(), - WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 선선한 날씨가 예정되어 있습니다...").emoji("shower").build(), - WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 무난한 날씨가 예정되어 있습니다...").emoji("shower").build(), - WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 무더운 날씨가 예정되어 있습니다...").emoji("shower").build() + // ============ 1. 맑음 (CLEAR) - 강수확률 낮음만 ============ + // 맑음 + 모든 기온 + 강수확률 없음/매우 낮음 + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.NONE).message("맑은 하늘과 쌀쌀한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 쌀쌀한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.NONE).message("맑은 하늘과 선선한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 선선한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.NONE).message("맑은 하늘과 무난한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 무난한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.NONE).message("맑은 하늘과 무더운 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 무더운 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + + // ============ 2. 흐림 (CLOUDY) - 중간 강수확률 ============ + // 흐림 + 모든 기온 + 약간 낮음/낮음/높음 + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_LOW).message("흐린 하늘과 쌀쌀한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 쌀쌀한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_LOW).message("흐린 하늘과 선선한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 선선한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.VERY_LOW).message("흐린 하늘과 무난한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 무난한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.VERY_LOW).message("흐린 하늘과 무더운 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 무더운 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + + // ============ 3. 비 (RAINY) - 강수확률 높음만 ============ + // 비 + 모든 기온 + 높음/매우 높음 + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("비와 함께 쌀쌀한 날씨가 예정되어 있습니다.비 오는 날엔 실내에서 여유롭게 보내는 감성 데이트를 추천해요.").emoji("rainy").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_HIGH).message("비와 함께 쌀쌀한 날씨가 예정되어 있습니다.우산이 필요한 오늘, 조용한 실내 공간에서 감성 가득한 시간을 보내보세요.").emoji("rainy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("비와 함께 선선한 날씨가 예정되어 있습니다.비 오는 날엔 실내에서 여유롭게 보내는 감성 데이트를 추천해요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_HIGH).message("비와 함께 선선한 날씨가 예정되어 있습니다.우산이 필요한 오늘, 조용한 실내 공간에서 감성 가득한 시간을 보내보세요.").emoji("rainy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.HIGH).message("비와 함께 무난한 날씨가 예정되어 있습니다.비 오는 날엔 실내에서 여유롭게 보내는 감성 데이트를 추천해요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.VERY_HIGH).message("비와 함께 무난한 날씨가 예정되어 있습니다.우산이 필요한 오늘, 조용한 실내 공간에서 감성 가득한 시간을 보내보세요.").emoji("rainy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.HIGH).message("비와 함께 무더운 날씨가 예정되어 있습니다.비 오는 날엔 실내에서 여유롭게 보내는 감성 데이트를 추천해요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.VERY_HIGH).message("비와 함께 무더운 날씨가 예정되어 있습니다.우산이 필요한 오늘, 조용한 실내 공간에서 감성 가득한 시간을 보내보세요.").emoji("rainy️").build(), + + // ============ 4. 눈 (SNOWY) - 추운 날씨만 ============ + // 눈 + 쌀쌀함/선선함만 + 약간 낮음/낮음/높음 + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_LOW).message("눈과 함께 쌀쌀한 날씨가 예정되어 있습니다.하얀 눈과 함께 감성적인 장소에서 특별한 하루를 만들어보세요.").emoji("snowy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("눈과 함께 쌀쌀한 날씨가 예정되어 있습니다.하얀 눈과 함께 감성적인 장소에서 특별한 하루를 만들어보세요.").emoji("snowy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("눈과 함께 쌀쌀한 날씨가 예정되어 있습니다.눈 내리는 풍경 속에서 감성과 추억이 가득한 데이트를 즐겨보세요.").emoji("snowy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_LOW).message("눈과 함께 선선한 날씨가 예정되어 있습니다.하얀 눈과 함께 감성적인 장소에서 특별한 하루를 만들어보세요.").emoji("snowy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("눈과 함께 선선한 날씨가 예정되어 있습니다.하얀 눈과 함께 감성적인 장소에서 특별한 하루를 만들어보세요.").emoji("snowy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("눈과 함께 선선한 날씨가 예정되어 있습니다.눈 내리는 풍경 속에서 감성과 추억이 가득한 데이트를 즐겨보세요.").emoji("snowy️").build(), + + // ============ 5. 비/눈 (RAIN_SNOW) - 추운 날씨만 ============ + // 비/눈 + 쌀쌀함/선선함만 + 낮음/높음 + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("비/눈이 같이오는 쌀쌀한 날씨가 예정되어 있습니다.변덕스러운 날씨엔 실내 전시나 감성 공간에서 편안한 데이트를 즐겨보세요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 쌀쌀한 날씨가 예정되어 있습니다.비와 눈이 섞인 날엔 실내에서 감성을 채우는 데이트가 좋아요.").emoji("rainy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("비/눈이 같이오는 선선한 날씨가 예정되어 있습니다.변덕스러운 날씨엔 실내 전시나 감성 공간에서 편안한 데이트를 즐겨보세요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 선선한 날씨가 예정되어 있습니다.비와 눈이 섞인 날엔 실내에서 감성을 채우는 데이트가 좋아요.").emoji("rainy️").build(), + + // ============ 6. 소나기 (SHOWER) - 모든 기온 가능 ============ + // 소나기 + 모든 기온 + 낮음/높음 + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 쌀쌀한 날씨가 예정되어 있습니다.소나기 예보가 있다면, 실내에서 여유롭게 보내는 하루는 어떠세요?").emoji("shower").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("소나기가 예정된 쌀쌀한 날씨가 예정되어 있습니다.갑작스러운 소나기를 피해, 조용한 북카페나 책방에서의 데이트를 추천해요.").emoji("shower️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 선선한 날씨가 예정되어 있습니다.소나기 예보가 있다면, 실내에서 여유롭게 보내는 하루는 어떠세요?").emoji("shower").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("소나기가 예정된 선선한 날씨가 예정되어 있습니다.갑작스러운 소나기를 피해, 조용한 북카페나 책방에서의 데이트를 추천해요.").emoji("shower️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 무난한 날씨가 예정되어 있습니다.소나기 예보가 있다면, 실내에서 여유롭게 보내는 하루는 어떠세요?").emoji("shower️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.HIGH).message("소나기가 예정된 무난한 날씨가 예정되어 있습니다.갑작스러운 소나기를 피해, 조용한 북카페나 책방에서의 데이트를 추천해요.").emoji("shower️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 무더운 날씨가 예정되어 있습니다.소나기 예보가 있다면, 실내에서 여유롭게 보내는 하루는 어떠세요?").emoji("shower️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.HIGH).message("소나기가 예정된 무더운 날씨가 예정되어 있습니다.갑작스러운 소나기를 피해, 조용한 북카페나 책방에서의 데이트를 추천해요.").emoji("shower️").build() ); weatherTemplateRepository.saveAll(templates); From 5961c685c5b7cd9cf30377636e217124a9e930bd Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Fri, 18 Jul 2025 11:31:46 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=E2=9C=A8feat:=20=EB=8B=A8=EA=B8=B0?= =?UTF-8?q?=EC=98=88=EB=B3=B4,=20=EC=A4=91=EA=B8=B0=EC=98=88=EB=B3=B4=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A1=9C=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=9D=EC=84=B1=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/WeatherClassificationConfig.java | 59 +++++ .../weather/converter/WeatherConverter.java | 121 ++++++++++ .../converter/WeatherSyncConverter.java | 48 ++++ .../data/scheduler/WeatherScheduler.java | 74 ++++++ .../data/service/WeatherApiClientImpl.java | 18 +- .../service/WeatherClassificationService.java | 17 ++ .../WeatherClassificationServiceImpl.java | 104 ++++++++ ...eatherRecommendationGenerationService.java | 16 ++ ...erRecommendationGenerationServiceImpl.java | 227 ++++++++++++++++++ .../utils/WeatherClassificationUtils.java | 153 ++++++++++++ .../weather/data/utils/WeatherDataHelper.java | 4 +- .../utils/WeatherRecommendationUtils.java | 93 +++++++ .../weather/dto/request/WeatherReqDTO.java | 45 ++++ .../weather/dto/response/WeatherResDTO.java | 141 +++++++++++ .../dto/response/WeatherSyncResDTO.java | 58 +++++ .../weather/entity/DailyRecommendation.java | 2 +- .../DailyRecommendationRepository.java | 44 ++++ .../RawMediumTermWeatherRepository.java | 14 ++ .../RawShortTermWeatherRepository.java | 14 ++ .../repository/WeatherTemplateRepository.java | 8 + .../command/WeatherTriggerServiceImpl.java | 16 ++ 21 files changed, 1273 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherClassificationConfig.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherConverter.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationService.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationServiceImpl.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherClassificationUtils.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherRecommendationUtils.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherClassificationConfig.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherClassificationConfig.java new file mode 100644 index 0000000..529cd8a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherClassificationConfig.java @@ -0,0 +1,59 @@ +package org.withtime.be.withtimebe.domain.weather.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Getter +@Setter +public class WeatherClassificationConfig { + + private TemperatureThresholds temperature = new TemperatureThresholds(); + private PrecipitationThresholds precipitation = new PrecipitationThresholds(); + + /** + * 기온 분류 임계값 + * 새로운 기획: 중앙값 기준 + */ + @Getter + @Setter + public static class TemperatureThresholds { + // 쌀쌀한 날씨 ≤ 10℃ + private double chillyCoolBoundary = 10.0; + + // 선선한 날씨 11~20℃ + private double coolMildBoundary = 20.0; + + // 무난한 날씨 21~25℃ + private double mildHotBoundary = 25.0; + + // 무더운 날씨 ≥ 26℃ + } + + /** + * 강수 분류 임계값 + * 새로운 기획: 강수확률 기반 + */ + @Getter + @Setter + public static class PrecipitationThresholds { + // 비 없음: 0% + private double noneVeryLowBoundary = 0.0; + + // 비 거의 없음: 1~30% + private double veryLowLowBoundary = 30.0; + + // 비 약간 가능성: 31~60% + private double lowHighBoundary = 60.0; + + // 비 올 가능성 높음: 61~90% + private double highVeryHighBoundary = 90.0; + + // 비 확실: 91~100% + + // 기존 강수량 임계값도 유지 (단기예보에서 사용할 수 있음) + private double lightAmountThreshold = 1.0; // 1mm 이상 가벼운 비 + private double heavyAmountThreshold = 10.0; // 10mm 이상 많은 비 + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherConverter.java new file mode 100644 index 0000000..d1734ad --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherConverter.java @@ -0,0 +1,121 @@ +package org.withtime.be.withtimebe.domain.weather.converter; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.DailyRecommendation; +import org.withtime.be.withtimebe.domain.weather.entity.Region; +import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class WeatherConverter { + + /** + * 데이터가 없는 날짜용 빈 DailyWeatherRecommendation 생성 + */ + private static WeatherResDTO.DailyWeatherRecommendation createEmptyDailyRecommendation(LocalDate date) { + return WeatherResDTO.DailyWeatherRecommendation.builder() + .forecastDate(date) + .weatherType(null) + .tempCategory(null) + .precipCategory(null) + .message("해당 날짜의 날씨 추천 정보가 없습니다.") + .emoji("❓") + .keywords(List.of("정보없음")) + .build(); + } + + /** + * DailyRecommendation을 DailyWeatherRecommendation DTO로 변환 + * 새로운 기획의 구조화된 응답 반영 + */ + public static WeatherResDTO.DailyWeatherRecommendation toDailyWeatherRecommendation( + DailyRecommendation recommendation, boolean hasRecommendation) { + + if (!hasRecommendation) { + return createEmptyDailyRecommendation(recommendation.getForecastDate()); + } + + WeatherTemplate template = recommendation.getWeatherTemplate(); + List keywords = template.getTemplateKeywords().stream() + .map(tk -> tk.getKeyword().getName()) + .distinct() + .collect(Collectors.toList()); + + return WeatherResDTO.DailyWeatherRecommendation.builder() + .forecastDate(recommendation.getForecastDate()) + .weatherType(template.getWeatherType()) + .tempCategory(template.getTempCategory()) + .precipCategory(template.getPrecipCategory()) + .message(template.getMessage()) + .emoji(template.getEmoji()) + .keywords(keywords) + .build(); + } + + /** + * DailyRecommendation 리스트를 WeeklyRecommendation DTO로 변환 + */ + public static WeatherResDTO.WeeklyRecommendation toWeeklyRecommendation( + List recommendations, Long regionId, String regionName, + LocalDate startDate, LocalDate endDate) { + + Map recommendationMap = recommendations.stream() + .collect(Collectors.toMap( + DailyRecommendation::getForecastDate, + rec -> rec + )); + + List dailyRecommendations = + startDate.datesUntil(endDate.plusDays(1)) + .map(date -> { + DailyRecommendation rec = recommendationMap.get(date); + if (rec != null) { + return toDailyWeatherRecommendation(rec, true); + } else { + return createEmptyDailyRecommendation(date); + } + }) + .collect(Collectors.toList()); + + WeatherResDTO.RegionInfo regionInfo; + if (!recommendations.isEmpty()) { + Region region = recommendations.get(0).getRegion(); + regionInfo = toRegionInfo(region); + } else { + regionInfo = WeatherResDTO.RegionInfo.builder() + .regionId(regionId) + .regionName(regionName) + .landRegCode(null) + .tempRegCode(null) + .build(); + } + + return WeatherResDTO.WeeklyRecommendation.builder() + .region(regionInfo) + .startDate(startDate) + .endDate(endDate) + .dailyRecommendations(dailyRecommendations) + .totalDays(dailyRecommendations.size()) + .message(String.format("%s 지역의 %s부터 %s까지 주간 날씨 추천입니다.", + regionName, startDate, endDate)) + .build(); + } + + /** + * Region 엔티티를 RegionInfo DTO로 변환 + */ + public static WeatherResDTO.RegionInfo toRegionInfo(Region region) { + return WeatherResDTO.RegionInfo.builder() + .regionId(region.getId()) + .regionName(region.getName()) + .landRegCode(region.getRegionCode().getLandRegCode()) + .tempRegCode(region.getRegionCode().getTempRegCode()) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java index 458d865..0622746 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java @@ -4,10 +4,12 @@ import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; @Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -89,4 +91,50 @@ public static WeatherSyncResDTO.MediumTermSyncResult toMediumTermSyncResult( .message(message) .build(); } + + /** + * 추천 생성 결과 생성 + */ + public static WeatherSyncResDTO.RecommendationGenerationResult toRecommendationGenerationResult( + int totalRegions, int successfulRegions, int failedRegions, + int totalRecommendations, int newRecommendations, int updatedRecommendations, + LocalDate startDate, LocalDate endDate, + LocalDateTime startTime, LocalDateTime endTime, + List regionResults, + Map weatherStats, + List errorMessages) { + + long durationMs = java.time.Duration.between(startTime, endTime).toMillis(); + String message = String.format( + "추천 정보 생성 완료: 성공 %d/%d 지역, 신규 %d개, 업데이트 %d개 추천 생성", + successfulRegions, totalRegions, newRecommendations, updatedRecommendations); + + WeatherSyncResDTO.WeatherTypeStatistics weatherTypeStats = WeatherSyncResDTO.WeatherTypeStatistics.builder() + .clearWeatherCount(weatherStats.getOrDefault(WeatherType.CLEAR, 0)) + .cloudyWeatherCount(weatherStats.getOrDefault(WeatherType.CLOUDY, 0)) + .cloudyRainCount(weatherStats.getOrDefault(WeatherType.RAINY, 0)) + .cloudySnowCount(weatherStats.getOrDefault(WeatherType.SNOWY, 0)) + .cloudyRainSnowCount(weatherStats.getOrDefault(WeatherType.RAIN_SNOW, 0)) + .cloudyShowerCount(weatherStats.getOrDefault(WeatherType.SHOWER, 0)) + .detailedStats(weatherStats) + .build(); + + return WeatherSyncResDTO.RecommendationGenerationResult.builder() + .totalRegions(totalRegions) + .successfulRegions(successfulRegions) + .failedRegions(failedRegions) + .totalRecommendations(totalRecommendations) + .newRecommendations(newRecommendations) + .updatedRecommendations(updatedRecommendations) + .startDate(startDate) + .endDate(endDate) + .processingStartTime(startTime) + .processingEndTime(endTime) + .processingDurationMs(durationMs) + .regionResults(regionResults) + .weatherStats(weatherTypeStats) + .errorMessages(errorMessages) + .message(message) + .build(); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java index cb671d0..9f84242 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java @@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCollectionService; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherRecommendationGenerationService; import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; @@ -20,10 +21,13 @@ public class WeatherScheduler { private final WeatherDataCollectionService weatherDataCollectionService; + private final WeatherRecommendationGenerationService weatherRecommendationGenerationService; // 스케줄러 실행 상태 volatile로 추척 private volatile boolean shortTermSyncRunning = false; private volatile boolean mediumTermSyncRunning = false; + private volatile boolean shortTermRecommendationRunning = false; + private volatile boolean mediumTermRecommendationRunning = false; /** * 단기 예보 데이터 수집 스케줄러 @@ -93,4 +97,74 @@ public void scheduledMediumTermWeatherSync() { mediumTermSyncRunning = false; } } + + /** + * 단기예보 기반 추천 정보 생성 스케줄러 (0-3일, 실제 단기예보 데이터 범위) + * 매 시간 5분에 실행 - 단기예보는 1시간마다 업데이트 + */ + @Scheduled(cron = "${scheduler.weather.recommendation.short-term-cron}") + @Async("weatherTaskExecutor") + public void scheduledShortTermRecommendationGeneration() { + if (shortTermRecommendationRunning) { + log.warn("단기예보 추천 생성이 이미 실행 중입니다. 스킵합니다."); + return; + } + + try { + shortTermRecommendationRunning = true; + log.info("단기예보 추천 생성 스케줄러 시작 (실제 단기예보 데이터 기반)"); + + // 오늘부터 4일간만 처리 (실제 단기예보 데이터가 있는 범위) + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(3); + + WeatherSyncResDTO.RecommendationGenerationResult result = + weatherRecommendationGenerationService.generateRecommendations( + null, startDate, endDate, true, "단기예보"); // 강제 재생성으로 최신 데이터 반영 + + log.info("단기예보 추천 생성 스케줄러 완료: 성공 {}/{} 지역, 신규 {} 건, 업데이트 {} 건", + result.successfulRegions(), result.totalRegions(), + result.newRecommendations(), result.updatedRecommendations()); + + } catch (Exception e) { + log.error("단기예보 추천 생성 스케줄러 실행 중 오류 발생", e); + } finally { + shortTermRecommendationRunning = false; + } + } + + /** + * 중기예보 기반 추천 정보 생성 스케줄러 (4-10일, 실제 중기예보 데이터 범위) + * 매 6시간 30분에 실행 - 중기예보는 12시간마다 업데이트되므로 6시간마다 충분 + */ + @Scheduled(cron = "${scheduler.weather.recommendation.medium-term-cron:0 30 0,6,12,18 * * *}") + @Async("weatherTaskExecutor") + public void scheduledMediumTermRecommendationGeneration() { + if (mediumTermRecommendationRunning) { + log.warn("중기예보 추천 생성이 이미 실행 중입니다. 스킵합니다."); + return; + } + + try { + mediumTermRecommendationRunning = true; + log.info("중기예보 추천 생성 스케줄러 시작 (실제 중기예보 데이터 기반)"); + + // 4일후부터 3일간만 처리 (일반적인 서비스 범위) + LocalDate startDate = LocalDate.now().plusDays(4); + LocalDate endDate = LocalDate.now().plusDays(6); + + WeatherSyncResDTO.RecommendationGenerationResult result = + weatherRecommendationGenerationService.generateRecommendations( + null, startDate, endDate, true, "중기예보"); // 강제 재생성 + + log.info("중기예보 추천 생성 스케줄러 완료: 성공 {}/{} 지역, 신규 {} 건, 업데이트 {} 건", + result.successfulRegions(), result.totalRegions(), + result.newRecommendations(), result.updatedRecommendations()); + + } catch (Exception e) { + log.error("중기예보 추천 생성 스케줄러 실행 중 오류 발생", e); + } finally { + mediumTermRecommendationRunning = false; + } + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java index 7a8920a..be647a4 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java @@ -8,7 +8,9 @@ import org.withtime.be.withtimebe.domain.weather.entity.Region; import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; import org.withtime.be.withtimebe.global.error.exception.WeatherException; +import reactor.util.retry.Retry; +import java.time.Duration; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -54,6 +56,11 @@ public String callShortTermWeatherApi(Region region, LocalDate baseDate, String .build()) .retrieve() .bodyToMono(String.class) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(2)) + .onRetryExhaustedThrow((retryBackoffSpec, signal) -> { + log.error("단기예보 API 3회 재시도 실패", signal.failure()); + return new WeatherException(WeatherErrorCode.SHORT_TERM_FORECAST_ERROR); + })) .block(); if (response == null || response.trim().isEmpty()) { @@ -89,6 +96,11 @@ public String callMediumTermLandWeatherApi(Region region, LocalDate tmfc) { .build()) .retrieve() .bodyToMono(String.class) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(2)) + .onRetryExhaustedThrow((retryBackoffSpec, signal) -> { + log.error("중기 육상예보 API 3회 재시도 실패", signal.failure()); + return new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); + })) .block(); if (response == null || response.trim().isEmpty()) { @@ -124,6 +136,11 @@ public String callMediumTermTempWeatherApi(Region region, LocalDate tmfc) { .build()) .retrieve() .bodyToMono(String.class) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(2)) + .onRetryExhaustedThrow((retryBackoffSpec, signal) -> { + log.error("중기 기온예보 API 3회 재시도 실패", signal.failure()); + return new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); + })) .block(); if (response == null || response.trim().isEmpty()) { @@ -139,5 +156,4 @@ public String callMediumTermTempWeatherApi(Region region, LocalDate tmfc) { throw new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); } } - } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationService.java new file mode 100644 index 0000000..c502ab9 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationService.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; + +import java.time.LocalDate; +import java.util.List; + +public interface WeatherClassificationService { + + WeatherResDTO.WeatherClassificationResult classifyShortTermWeatherWithCentralTemp( + List shortTermData, Long regionId, LocalDate targetDate); + + WeatherResDTO.WeatherClassificationResult classifyMediumTermWeather( + List mediumTermData, Long regionId, LocalDate targetDate); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationServiceImpl.java new file mode 100644 index 0000000..8c4ca9e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationServiceImpl.java @@ -0,0 +1,104 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.withtime.be.withtimebe.domain.weather.config.WeatherClassificationConfig; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherClassificationUtils; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; + +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherClassificationServiceImpl implements WeatherClassificationService { + + private final WeatherClassificationConfig config; + + @Override + public WeatherResDTO.WeatherClassificationResult classifyShortTermWeatherWithCentralTemp( + List shortTermData, Long regionId, LocalDate targetDate) { + + if (shortTermData.isEmpty()) { + log.warn("단기 예보 데이터가 없습니다: regionId={}, date={}", regionId, targetDate); + return createDefaultClassification(); + } + + try { + // 1. 강수확률용 대표 시간대 선택 + RawShortTermWeather representativeData = WeatherClassificationUtils.selectRepresentativeShortTermData(shortTermData, targetDate); + + // 2. 온도 중앙값 계산을 위한 최저/최고 온도 조회 + WeatherClassificationUtils.TemperatureRange tempRange = WeatherClassificationUtils.calculateTemperatureRange(shortTermData, targetDate); + + // 3. 날씨 유형 분류 + WeatherType weatherType = WeatherClassificationUtils.classifyWeatherTypeFromShortTerm(representativeData); + + // 4. 온도 분류 - 중앙값 사용 + TempCategory tempCategory = WeatherClassificationUtils.classifyTempCategoryFromRange( + tempRange.minTemp(), tempRange.maxTemp(), config.getTemperature()); + + // 5. 강수 분류 - 현재 시간 기준 시간대 사용 + PrecipCategory precipCategory = WeatherClassificationUtils.classifyPrecipCategoryByProbability( + representativeData.getPrecipitationProbability(), config.getPrecipitation()); + + log.debug("단기예보 중앙값 분류 완료: regionId={}, date={}, weather={}, temp={}, precip={}, 온도범위={}~{}°C, 강수확률={}%", + regionId, targetDate, weatherType, tempCategory, precipCategory, + tempRange.minTemp(), tempRange.maxTemp(), representativeData.getPrecipitationProbability()); + + return new WeatherResDTO.WeatherClassificationResult(weatherType, tempCategory, precipCategory, + tempRange.getAvgTemp(), representativeData.getPrecipitationProbability(), + representativeData.getPrecipitationAmount(), "단기예보(중앙값)"); + + } catch (Exception e) { + log.error("단기예보 중앙값 분류 중 오류 발생: regionId={}, date={}", regionId, targetDate, e); + return createDefaultClassification(); + } + } + + @Override + public WeatherResDTO.WeatherClassificationResult classifyMediumTermWeather( + List mediumTermData, Long regionId, LocalDate targetDate) { + + if (mediumTermData.isEmpty()) { + log.warn("중기 예보 데이터가 없습니다: regionId={}, date={}", regionId, targetDate); + return createDefaultClassification(); + } + + try { + RawMediumTermWeather representativeData = WeatherClassificationUtils.selectRepresentativeMediumTermData(mediumTermData, targetDate); + + WeatherType weatherType = WeatherClassificationUtils.classifyWeatherTypeFromMediumTerm(representativeData); + TempCategory tempCategory = WeatherClassificationUtils.classifyTempCategoryFromRange( + representativeData.getMinTemperature(), representativeData.getMaxTemperature(), config.getTemperature()); + PrecipCategory precipCategory = WeatherClassificationUtils.classifyPrecipCategoryByProbability( + representativeData.getPrecipitationProbability(), config.getPrecipitation()); + + log.debug("중기 예보 분류 완료: regionId={}, date={}, weather={}, temp={}, precip={}, 최저={}°C, 최고={}°C, 강수확률={}%%", + regionId, targetDate, weatherType, tempCategory, precipCategory, + representativeData.getMinTemperature(), representativeData.getMaxTemperature(), representativeData.getPrecipitationProbability()); + + double avgTemp = (representativeData.getMinTemperature() + representativeData.getMaxTemperature()) / 2.0; + return new WeatherResDTO.WeatherClassificationResult(weatherType, tempCategory, precipCategory, + avgTemp, representativeData.getPrecipitationProbability(), 0.0, "중기예보"); + + } catch (Exception e) { + log.error("중기 예보 분류 중 오류 발생: regionId={}, date={}", regionId, targetDate, e); + return createDefaultClassification(); + } + } + + private WeatherResDTO.WeatherClassificationResult createDefaultClassification() { + return new WeatherResDTO.WeatherClassificationResult( + WeatherType.CLOUDY, TempCategory.MILD, PrecipCategory.NONE, + 20.0, 30.0, 0.0, "기본값"); + } +} + diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java new file mode 100644 index 0000000..6d39e79 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java @@ -0,0 +1,16 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; + +import java.time.LocalDate; +import java.util.List; + +public interface WeatherRecommendationGenerationService { + + WeatherSyncResDTO.RecommendationGenerationResult generateRecommendations( + List regionIds, LocalDate startDate, LocalDate endDate, boolean forceRegenerate, String recommendationType); + + WeatherResDTO.WeeklyRecommendation getWeeklyRecommendation(WeatherReqDTO.GetWeeklyRecommendation request); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java new file mode 100644 index 0000000..f0007a9 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java @@ -0,0 +1,227 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.weather.converter.WeatherConverter; +import org.withtime.be.withtimebe.domain.weather.converter.WeatherSyncConverter; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherRecommendationUtils; +import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.*; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; +import org.withtime.be.withtimebe.domain.weather.repository.*; +import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; +import org.withtime.be.withtimebe.global.error.exception.WeatherException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherRecommendationGenerationServiceImpl implements WeatherRecommendationGenerationService { + + private final RegionRepository regionRepository; + private final RawShortTermWeatherRepository shortTermWeatherRepository; + private final RawMediumTermWeatherRepository mediumTermWeatherRepository; + private final WeatherTemplateRepository weatherTemplateRepository; + private final DailyRecommendationRepository dailyRecommendationRepository; + private final WeatherClassificationService classificationService; + + @Override + @Transactional + public WeatherSyncResDTO.RecommendationGenerationResult generateRecommendations( + List regionIds, LocalDate startDate, LocalDate endDate, + boolean forceRegenerate, String recommendationType) { + + LocalDateTime startTime = LocalDateTime.now(); + log.info("{} 추천 정보 생성 시작: regionIds={}, startDate={}, endDate={}, forceRegenerate={}", + recommendationType, regionIds, startDate, endDate, forceRegenerate); + + List targetRegions = WeatherDataHelper.getTargetRegions(regionIds, regionRepository); + List regionResults = new ArrayList<>(); + List errorMessages = new ArrayList<>(); + Map weatherStats = new HashMap<>(); + + Map templateMap = WeatherRecommendationUtils.createTemplateMap(weatherTemplateRepository.findAllWithKeywords()); + + for (Region region : targetRegions) { + long regionStartTime = System.currentTimeMillis(); + try { + RegionRecommendationResult result = generateRecommendationsForRegion( + region, startDate, endDate, forceRegenerate, templateMap, recommendationType); + + updateStats(regionResults, weatherStats, region, result, regionStartTime); + + } catch (Exception e) { + handleError(regionResults, errorMessages, region, e, regionStartTime, recommendationType); + } + } + + LocalDateTime endTime = LocalDateTime.now(); + log.info("{} 추천 정보 생성 완료: 성공 {}/{} 지역, 신규 {}, 업데이트 {} 추천, 처리시간 {}ms", + recommendationType, + regionResults.stream().filter(WeatherSyncResDTO.RegionRecommendationResult::success).count(), + targetRegions.size(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::newRecommendations).sum(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::updatedRecommendations).sum(), + ChronoUnit.MILLIS.between(startTime, endTime)); + + return WeatherSyncConverter.toRecommendationGenerationResult( + targetRegions.size(), + (int) regionResults.stream().filter(WeatherSyncResDTO.RegionRecommendationResult::success).count(), + (int) regionResults.stream().filter(r -> !r.success()).count(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::recommendationsGenerated).sum(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::newRecommendations).sum(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::updatedRecommendations).sum(), + startDate, endDate, startTime, endTime, regionResults, weatherStats, errorMessages); + } + + @Override + public WeatherResDTO.WeeklyRecommendation getWeeklyRecommendation( + WeatherReqDTO.GetWeeklyRecommendation request) { + log.info("주간 날씨 추천 조회 요청: regionId={}, startDate={}", + request.regionId(), request.startDate()); + + // 1. 지역 존재 확인 + Region region = validateRegionExists(request.regionId()); + + // 2. 주간 추천 정보 조회 + LocalDate endDate = request.getEndDate(); + List recommendations = + dailyRecommendationRepository.findWeeklyRecommendations( + request.regionId(), request.startDate(), endDate.plusDays(1)); + + log.info("주간 날씨 추천 조회 완료: regionId={}, 조회된 데이터 수={}", + request.regionId(), recommendations.size()); + + return WeatherConverter.toWeeklyRecommendation( + recommendations, region.getId(), region.getName(), + request.startDate(), endDate); + } + + private void updateStats(List regionResults, + Map weatherStats, + Region region, RegionRecommendationResult result, long startTime) { + + WeatherRecommendationUtils.mergeWeatherStats(weatherStats, result.weatherTypeStats()); + + regionResults.add(WeatherSyncResDTO.RegionRecommendationResult.builder() + .regionId(region.getId()) + .regionName(region.getName()) + .success(true) + .recommendationsGenerated(result.recommendationsGenerated()) + .newRecommendations(result.newRecommendations()) + .updatedRecommendations(result.updatedRecommendations()) + .processedDates(result.processedDates()) + .errorMessage(null) + .processingTimeMs(System.currentTimeMillis() - startTime) + .build()); + } + + private void handleError(List regionResults, + List errorMessages, + Region region, Exception e, long startTime, String type) { + long timeTaken = System.currentTimeMillis() - startTime; + String msg = String.format("지역 %s 추천 생성 실패: %s", region.getName(), e.getMessage()); + log.error("{} 추천 생성: 지역 {} 실패", type, region.getName(), e); + errorMessages.add(msg); + regionResults.add(WeatherSyncResDTO.RegionRecommendationResult.builder() + .regionId(region.getId()) + .regionName(region.getName()) + .success(false) + .recommendationsGenerated(0) + .newRecommendations(0) + .updatedRecommendations(0) + .processedDates(Collections.emptyList()) + .errorMessage(msg) + .processingTimeMs(timeTaken) + .build()); + } + + private RegionRecommendationResult generateRecommendationsForRegion( + Region region, LocalDate startDate, LocalDate endDate, + boolean forceRegenerate, Map templateMap, String type) { + + List processedDates = new ArrayList<>(); + Map stats = new HashMap<>(); + int generated = 0, created = 0, updated = 0; + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + try { + RecommendationResult result = generateRecommendationForDate(region, date, forceRegenerate, templateMap); + if (result != null) { + generated++; + if (result.isNew()) created++; else updated++; + processedDates.add(date.toString()); + stats.merge(result.weatherType(), 1, Integer::sum); + } + } catch (Exception e) { + log.warn("{} 추천 생성 실패: {} {}", type, region.getName(), date); + } + } + + return new RegionRecommendationResult(generated, created, updated, processedDates, stats); + } + + private RecommendationResult generateRecommendationForDate(Region region, LocalDate date, + boolean forceRegenerate, Map templateMap) { + + Optional existing = dailyRecommendationRepository.findByRegionIdAndDateWithTemplate(region.getId(), date); + if (existing.isPresent() && !forceRegenerate) return null; + + WeatherResDTO.WeatherClassificationResult classification = classifyWeatherForDate(region, date); + if (!classification.isValid()) return null; + + WeatherTemplate template = WeatherRecommendationUtils.findMatchingTemplate(classification, templateMap); + if (template == null) return null; + + existing.ifPresent(dailyRecommendationRepository::delete); + + dailyRecommendationRepository.save( + DailyRecommendation.builder() + .region(region) + .weatherTemplate(template) + .forecastDate(date) + .build() + ); + + return new RecommendationResult(classification.weatherType(), existing.isEmpty()); + } + + private WeatherResDTO.WeatherClassificationResult classifyWeatherForDate(Region region, LocalDate date) { + // 1. 중기예보 우선 조회 + List mediumTermData = mediumTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); + if (!mediumTermData.isEmpty()) { + return classificationService.classifyMediumTermWeather(mediumTermData, region.getId(), date); + } + + // 2. 중기예보 없으면 단기예보 사용 (온도 중앙값 적용) + List shortTermData = shortTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); + if (!shortTermData.isEmpty()) { + return classificationService.classifyShortTermWeatherWithCentralTemp(shortTermData, region.getId(), date); + } + + throw new WeatherException(WeatherErrorCode.WEATHER_DATA_NOT_FOUND); + } + + private Region validateRegionExists(Long regionId) { + return regionRepository.findById(regionId) + .orElseThrow(() -> { + log.error("존재하지 않는 지역: regionId={}", regionId); + return new WeatherException(WeatherErrorCode.REGION_NOT_FOUND); + }); + } + + private record RegionRecommendationResult(int recommendationsGenerated, int newRecommendations, + int updatedRecommendations, List processedDates, + Map weatherTypeStats) {} + + private record RecommendationResult(WeatherType weatherType, boolean isNew) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherClassificationUtils.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherClassificationUtils.java new file mode 100644 index 0000000..01846b7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherClassificationUtils.java @@ -0,0 +1,153 @@ +package org.withtime.be.withtimebe.domain.weather.data.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.withtime.be.withtimebe.domain.weather.config.WeatherClassificationConfig; +import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class WeatherClassificationUtils { + + public static RawShortTermWeather selectRepresentativeShortTermData(List dataList, LocalDate targetDate) { + return dataList.stream() + .filter(data -> data.getForecastDate().equals(targetDate)) + .sorted((a, b) -> { + int baseDateCompare = b.getBaseDate().compareTo(a.getBaseDate()); + if (baseDateCompare != 0) return baseDateCompare; + int baseTimeCompare = b.getBaseTime().compareTo(a.getBaseTime()); + if (baseTimeCompare != 0) return baseTimeCompare; + int aTimeScore = getTimeScore(a.getForecastTime()); + int bTimeScore = getTimeScore(b.getForecastTime()); + return Integer.compare(bTimeScore, aTimeScore); + }) + .findFirst() + .orElse(dataList.get(0)); + } + + public static RawMediumTermWeather selectRepresentativeMediumTermData(List dataList, LocalDate targetDate) { + return dataList.stream() + .filter(data -> data.getForecastDate().equals(targetDate)) + .max(Comparator.comparing(RawMediumTermWeather::getBaseDate)) + .orElse(dataList.get(0)); + } + + private static int getTimeScore(String fcstTime) { + try { + LocalTime currentTime = LocalTime.now(); + + // 예보 시간 파싱 + int fcstHour = Integer.parseInt(fcstTime.substring(0, 2)); + int fcstMinute = Integer.parseInt(fcstTime.substring(2, 4)); + LocalTime forecastTime = LocalTime.of(fcstHour, fcstMinute); + + // 현재 시간과 예보 시간 차이 계산 (분 단위) + long timeDifference = Math.abs(ChronoUnit.MINUTES.between(currentTime, forecastTime)); + + // 시간 차이가 작을수록 높은 점수 부여 + // 12시간(720분) 이상 차이나면 0점 + if (timeDifference >= 720) { + return 0; + } + + // 점수 계산: 최대 1000점에서 시간 차이만큼 감점 + // 720분 차이까지 선형적으로 감소 + int score = Math.max(0, 1000 - (int)(timeDifference * 1000 / 720)); + + return score; + + } catch (Exception e) { + log.warn("시간 점수 계산 중 오류 발생: fcstTime={}", fcstTime, e); + return 50; // 기본값 반환 + } + } + + public static WeatherType classifyWeatherTypeFromShortTerm(RawShortTermWeather data) { + String pty = data.getPrecipitationType(); + String sky = data.getSky(); + + return switch (pty) { + case "눈" -> WeatherType.SNOWY; + case "비/눈" -> WeatherType.RAIN_SNOW; + case "비", "빗방울" -> WeatherType.RAINY; + case "눈날림", "빗방울눈날림" -> WeatherType.SHOWER; + default -> switch (sky) { + case "맑음" -> WeatherType.CLEAR; + case "구름많음", "흐림" -> WeatherType.CLOUDY; + default -> WeatherType.CLOUDY; + }; + }; + } + + public static WeatherType classifyWeatherTypeFromMediumTerm(RawMediumTermWeather data) { + return switch (data.getSky()) { + case "맑음" -> WeatherType.CLEAR; + case "구름많음", "흐림" -> WeatherType.CLOUDY; + case "눈" -> WeatherType.SNOWY; + case "비" -> WeatherType.RAINY; + case "소나기" -> WeatherType.SHOWER; + default -> WeatherType.CLOUDY; + }; + } + + public static TempCategory classifyTempCategory(Double temperature, WeatherClassificationConfig.TemperatureThresholds thresholds) { + if (temperature == null) return TempCategory.MILD; + if (temperature <= thresholds.getChillyCoolBoundary()) return TempCategory.CHILLY; + else if (temperature <= thresholds.getCoolMildBoundary()) return TempCategory.COOL; + else if (temperature <= thresholds.getMildHotBoundary()) return TempCategory.MILD; + else return TempCategory.HOT; + } + + public static TempCategory classifyTempCategoryFromRange(Double minTemp, Double maxTemp, WeatherClassificationConfig.TemperatureThresholds thresholds) { + if (minTemp == null || maxTemp == null) return TempCategory.MILD; + double avgTemp = (minTemp + maxTemp) / 2.0; + if (maxTemp > thresholds.getMildHotBoundary() + 3) return TempCategory.HOT; + return classifyTempCategory(avgTemp, thresholds); + } + + public static PrecipCategory classifyPrecipCategoryByProbability(Double precipProbability, WeatherClassificationConfig.PrecipitationThresholds thresholds) { + if (precipProbability == null) precipProbability = 0.0; + if (precipProbability <= thresholds.getNoneVeryLowBoundary()) return PrecipCategory.NONE; + else if (precipProbability <= thresholds.getVeryLowLowBoundary()) return PrecipCategory.VERY_LOW; + else if (precipProbability <= thresholds.getLowHighBoundary()) return PrecipCategory.LOW; + else if (precipProbability <= thresholds.getHighVeryHighBoundary()) return PrecipCategory.HIGH; + else return PrecipCategory.VERY_HIGH; + } + + public static TemperatureRange calculateTemperatureRange(List dataList, LocalDate targetDate) { + List temperatures = dataList.stream() + .filter(data -> data.getForecastDate().equals(targetDate)) + .map(RawShortTermWeather::getTemperature) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + if (temperatures.isEmpty()) { + return new TemperatureRange(20.0, 20.0); // 기본값 + } + + double minTemp = temperatures.stream().mapToDouble(Double::doubleValue).min().orElse(20.0); + double maxTemp = temperatures.stream().mapToDouble(Double::doubleValue).max().orElse(20.0); + + return new TemperatureRange(minTemp, maxTemp); + } + + public record TemperatureRange(Double minTemp, Double maxTemp) { + public Double getAvgTemp() { + return (minTemp + maxTemp) / 2.0; + } + } +} + diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java index 9222964..1f99e86 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java @@ -1,5 +1,7 @@ package org.withtime.be.withtimebe.domain.weather.data.utils; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; @@ -13,7 +15,7 @@ import java.util.Optional; @Slf4j -@Component +@NoArgsConstructor(access = AccessLevel.PRIVATE) public class WeatherDataHelper { // 지역 ID가 없으면 전체, 있으면 ID 기반 조회 diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherRecommendationUtils.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherRecommendationUtils.java new file mode 100644 index 0000000..fd404ac --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherRecommendationUtils.java @@ -0,0 +1,93 @@ +package org.withtime.be.withtimebe.domain.weather.data.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class WeatherRecommendationUtils { + + public static String createTemplateKey(WeatherType weatherType, TempCategory tempCategory, PrecipCategory precipCategory) { + return String.format("%s_%s_%s", weatherType, tempCategory, precipCategory); + } + + public static Map createTemplateMap(List templates) { + return templates.stream().collect(Collectors.toMap( + t -> createTemplateKey(t.getWeatherType(), t.getTempCategory(), t.getPrecipCategory()), + t -> t, + (existing, duplicate) -> { + log.debug("중복 템플릿 키 유지: {}", existing.getId()); + return existing; + } + )); + } + + public static void mergeWeatherStats(Map globalStats, + Map regionStats) { + for (Map.Entry entry : regionStats.entrySet()) { + globalStats.merge(entry.getKey(), entry.getValue(), Integer::sum); + } + } + + public static WeatherTemplate findMatchingTemplate( + WeatherResDTO.WeatherClassificationResult classification, + Map templateMap) { + + String key = createTemplateKey(classification.weatherType(), classification.tempCategory(), classification.precipCategory()); + WeatherTemplate template = templateMap.get(key); + + if (template != null) { + log.trace("정확 템플릿 매칭 성공: {} -> ID={}", key, template.getId()); + return template; + } + + log.debug("정확 매칭 실패: {}, 대체 템플릿 탐색 시작", key); + return findFallbackTemplate(classification, templateMap); + } + + private static WeatherTemplate findFallbackTemplate( + WeatherResDTO.WeatherClassificationResult classification, + Map templateMap) { + + for (PrecipCategory fallback : generatePrecipFallbackOrder(classification.precipCategory())) { + String altKey = createTemplateKey(classification.weatherType(), classification.tempCategory(), fallback); + WeatherTemplate altTemplate = templateMap.get(altKey); + if (altTemplate != null) { + log.debug("대체 템플릿 매칭: {} -> ID={} (from {})", altKey, altTemplate.getId(), classification.precipCategory()); + return altTemplate; + } + } + + return findByWeatherAndTempOnly(classification.weatherType(), classification.tempCategory(), templateMap); + } + + private static WeatherTemplate findByWeatherAndTempOnly(WeatherType weatherType, TempCategory tempCategory, + Map templateMap) { + for (PrecipCategory category : PrecipCategory.values()) { + String key = createTemplateKey(weatherType, tempCategory, category); + WeatherTemplate template = templateMap.get(key); + if (template != null) return template; + } + return null; + } + + private static List generatePrecipFallbackOrder(PrecipCategory current) { + return switch (current) { + case VERY_HIGH -> List.of(PrecipCategory.VERY_HIGH, PrecipCategory.HIGH, PrecipCategory.LOW, PrecipCategory.VERY_LOW, PrecipCategory.NONE); + case HIGH -> List.of(PrecipCategory.HIGH, PrecipCategory.VERY_HIGH, PrecipCategory.LOW, PrecipCategory.VERY_LOW, PrecipCategory.NONE); + case LOW -> List.of(PrecipCategory.LOW, PrecipCategory.HIGH, PrecipCategory.VERY_LOW, PrecipCategory.VERY_HIGH, PrecipCategory.NONE); + case VERY_LOW -> List.of(PrecipCategory.VERY_LOW, PrecipCategory.LOW, PrecipCategory.NONE, PrecipCategory.HIGH, PrecipCategory.VERY_HIGH); + case NONE -> List.of(PrecipCategory.NONE, PrecipCategory.VERY_LOW, PrecipCategory.LOW, PrecipCategory.HIGH, PrecipCategory.VERY_HIGH); + }; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java new file mode 100644 index 0000000..348f7c8 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java @@ -0,0 +1,45 @@ +package org.withtime.be.withtimebe.domain.weather.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.time.LocalDate; + +public class WeatherReqDTO { + + public record GetWeeklyRecommendation( + @NotNull(message = "지역 ID는 필수 입력값입니다.") + @Positive(message = "지역 ID는 양수여야 합니다.") + Long regionId, + + @NotNull(message = "시작 날짜는 필수 입력값입니다.") + LocalDate startDate + ) { + /** + * 시작 날짜 유효성 검증을 포함한 정적 팩토리 메서드 + * 과거 7일 ~ 미래 7일까지만 조회 가능 + */ + public static GetWeeklyRecommendation of(Long regionId, LocalDate startDate) { + if (startDate != null) { + LocalDate now = LocalDate.now(); + LocalDate minDate = now.minusDays(7); + LocalDate maxDate = now.plusDays(7); + + if (startDate.isBefore(minDate) || startDate.isAfter(maxDate)) { + throw new IllegalArgumentException( + "조회 가능한 시작 날짜 범위를 벗어났습니다. (7일 전 ~ 7일 후)"); + } + } + + return new GetWeeklyRecommendation(regionId, startDate); + } + + /** + * 종료 날짜 계산 (시작일 + 6일) + */ + public LocalDate getEndDate() { + return startDate.plusDays(6); + } + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java new file mode 100644 index 0000000..0e39403 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java @@ -0,0 +1,141 @@ +package org.withtime.be.withtimebe.domain.weather.dto.response; + +import lombok.Builder; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public class WeatherResDTO { + + public record WeatherClassificationResult( + WeatherType weatherType, + TempCategory tempCategory, + PrecipCategory precipCategory, + Double temperature, + Double precipProbability, + Double precipAmount, + String dataSource + ) { + public boolean isValid() { + return weatherType != null && tempCategory != null && precipCategory != null; + } + + public String getSummary() { + return String.format("%s, %s (%.1f°C, %.0f%%) [%s]", + weatherType, tempCategory, temperature, precipProbability, dataSource); + } + } + + /** + * 날씨 추천 정보 (단일 날짜) + */ + @Builder + public record WeatherRecommendation( + Long recommendationId, + LocalDate forecastDate, + RegionInfo region, + WeatherInfo weather, + RecommendationInfo recommendation, + LocalDateTime updatedAt + ) { + } + + + /** + * 지역 정보 (날씨 관련 응답용) + */ + @Builder + public record RegionInfo( + Long regionId, + String regionName, + String landRegCode, + String tempRegCode + ) { + } + + /** + * 추천 정보 (메시지, 이모지, 키워드) + */ + @Builder + public record RecommendationInfo( + String message, + String emoji, + List keywords + ) { + } + + /** + * 주간 날씨 추천 정보 (7일치) + */ + @Builder + public record WeeklyRecommendation( + RegionInfo region, + LocalDate startDate, + LocalDate endDate, + List dailyRecommendations, + int totalDays, + String message + ) { + } + + /** + * 날씨 정보 (강수확률 정보 포함) + */ + @Builder + public record WeatherInfo( + WeatherType weatherType, // CLEAR, CLOUDY, CLOUDY_RAIN 등 + TempCategory tempCategory, // CHILLY, COOL, MILD, HOT + PrecipCategory precipCategory, // NONE, VERY_LOW, LOW, HIGH, VERY_HIGH + String weatherDescription, // "맑고", "흐리고", "비오고" 등 + String tempDescription, // "쌀쌀한 날", "선선한 날", "무난한 날", "무더운 날" + String precipDescription // "비 없음", "비 거의 없음", "비 약간 가능성" 등 + ) { + } + + /** + * 일별 날씨 추천 (강수확률 정보 포함) + */ + @Builder + public record DailyWeatherRecommendation( + LocalDate forecastDate, + WeatherType weatherType, + TempCategory tempCategory, + PrecipCategory precipCategory, + String message, + String emoji, + List keywords + ) { + } + + /** + * 날씨 추천 조회 실패 응답 (데이터 없음) + */ + @Builder + public record WeatherRecommendationNotFound( + Long regionId, + String regionName, + LocalDate requestedDate, + String message, + List suggestions // 대안 제안 (예: 가장 가까운 날짜의 데이터) + ) { + } + + + /** + * 간단한 날씨 정보 (목록 조회용) + */ + @Builder + public record WeatherRecommendationSummary( + Long recommendationId, + LocalDate forecastDate, + String regionName, + WeatherType weatherType, + String emoji, + String shortMessage // 메시지의 첫 50자 + ) { + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java index c595b09..09c46b5 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java @@ -1,10 +1,12 @@ package org.withtime.be.withtimebe.domain.weather.dto.response; import lombok.Builder; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; public class WeatherSyncResDTO { @@ -88,6 +90,7 @@ public record ManualTriggerResult( public record CompleteSyncResult( ShortTermSyncResult shortTermResult, // 단기 예보 결과 MediumTermSyncResult mediumTermResult, // 중기 예보 결과 + RecommendationGenerationResult recommendationResult, // 추천 생성 결과 LocalDateTime overallStartTime, // 전체 시작 시간 LocalDateTime overallEndTime, // 전체 종료 시간 long overallDurationMs, // 전체 소요 시간 (밀리초) @@ -97,4 +100,59 @@ public record CompleteSyncResult( ) { } + /** + * 지역별 추천 생성 결과 + */ + @Builder + public record RegionRecommendationResult( + Long regionId, + String regionName, + boolean success, + int recommendationsGenerated, + int newRecommendations, + int updatedRecommendations, + List processedDates, // 처리된 날짜들 + String errorMessage, + long processingTimeMs + ) { + } + + /** + * 날씨 타입별 통계 + */ + @Builder + public record WeatherTypeStatistics( + int clearWeatherCount, + int cloudyWeatherCount, + int cloudyRainCount, + int cloudySnowCount, + int cloudyRainSnowCount, + int cloudyShowerCount, + Map detailedStats + ) { + } + + /** + * 추천 정보 생성 결과 DTO + */ + @Builder + public record RecommendationGenerationResult( + int totalRegions, // 처리된 지역 수 + int successfulRegions, // 성공한 지역 수 + int failedRegions, // 실패한 지역 수 + int totalRecommendations, // 전체 생성된 추천 수 + int newRecommendations, // 새로 생성된 추천 수 + int updatedRecommendations, // 업데이트된 추천 수 + LocalDate startDate, // 시작 날짜 + LocalDate endDate, // 종료 날짜 + LocalDateTime processingStartTime, // 처리 시작 시간 + LocalDateTime processingEndTime, // 처리 종료 시간 + long processingDurationMs, // 처리 소요 시간 (밀리초) + List regionResults, // 지역별 결과 + WeatherTypeStatistics weatherStats, // 날씨별 통계 + List errorMessages, // 오류 메시지들 + String message // 전체 결과 메시지 + ) { + } + } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/DailyRecommendation.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/DailyRecommendation.java index 75d7c6e..ff2b397 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/DailyRecommendation.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/DailyRecommendation.java @@ -19,7 +19,7 @@ public class DailyRecommendation extends BaseEntity { @Column(name = "daily_recommendation_id") private Long id; - @Column(name = "forecate_date", nullable = false) + @Column(name = "forecast_date", nullable = false) private LocalDate forecastDate; @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java new file mode 100644 index 0000000..893423e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java @@ -0,0 +1,44 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.withtime.be.withtimebe.domain.weather.entity.DailyRecommendation; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface DailyRecommendationRepository extends JpaRepository { + + /** + * 특정 지역, 특정 날짜의 추천 정보 조회 + * WeatherTemplate, Keyword 정보까지 함께 fetch join + */ + @Query("SELECT dr FROM DailyRecommendation dr " + + "JOIN FETCH dr.weatherTemplate wt " + + "JOIN FETCH wt.templateKeywords tk " + + "JOIN FETCH tk.keyword k " + + "WHERE dr.region.id = :regionId " + + "AND dr.forecastDate = :date") + Optional findByRegionIdAndDateWithTemplate( + @Param("regionId") Long regionId, + @Param("date") LocalDate date); + + /** + * 특정 지역의 주간 추천 정보 조회 (7일치) + * 시작 날짜부터 7일간의 데이터 조회 + */ + @Query("SELECT dr FROM DailyRecommendation dr " + + "JOIN FETCH dr.weatherTemplate wt " + + "JOIN FETCH wt.templateKeywords tk " + + "JOIN FETCH tk.keyword k " + + "WHERE dr.region.id = :regionId " + + "AND dr.forecastDate >= :startDate " + + "AND dr.forecastDate < :endDate " + + "ORDER BY dr.forecastDate ASC") + List findWeeklyRecommendations( + @Param("regionId") Long regionId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java index 2d1187b..958a2d4 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java @@ -1,13 +1,27 @@ package org.withtime.be.withtimebe.domain.weather.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface RawMediumTermWeatherRepository extends JpaRepository { Optional findByRegionIdAndBaseDateAndForecastDate( Long regionId, LocalDate baseDate, LocalDate forecastDate); + + /** + * 특정 지역의 특정 날짜 중기 예보 데이터 조회 (분류용) + */ + @Query("SELECT rmtw FROM RawMediumTermWeather rmtw " + + "WHERE rmtw.region.id = :regionId " + + "AND rmtw.forecastDate = :forecastDate " + + "ORDER BY rmtw.baseDate DESC") + List findLatestByRegionIdAndForecastDate( + @Param("regionId") Long regionId, + @Param("forecastDate") LocalDate forecastDate); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java index 2af1d25..de145e5 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java @@ -1,9 +1,12 @@ package org.withtime.be.withtimebe.domain.weather.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface RawShortTermWeatherRepository extends JpaRepository { @@ -11,4 +14,15 @@ public interface RawShortTermWeatherRepository extends JpaRepository findByRegionIdAndBaseDateAndBaseTimeAndForecastDateAndForecastTime( Long regionId, LocalDate baseDate, String baseTime, LocalDate forecastDate, String forecastTime ); + + /** + * 특정 지역의 특정 날짜 예보 데이터 조회 (분류용) + */ + @Query("SELECT rstw FROM RawShortTermWeather rstw " + + "WHERE rstw.region.id = :regionId " + + "AND rstw.forecastDate = :forecastDate " + + "ORDER BY rstw.baseDate DESC, rstw.baseTime DESC") + List findLatestByRegionIdAndForecastDate( + @Param("regionId") Long regionId, + @Param("forecastDate") LocalDate forecastDate); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java index bca7720..3dd34fb 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java @@ -1,7 +1,15 @@ package org.withtime.be.withtimebe.domain.weather.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate; +import java.util.List; + public interface WeatherTemplateRepository extends JpaRepository { + + @Query("SELECT wt FROM WeatherTemplate wt " + + "JOIN FETCH wt.templateKeywords tk " + + "JOIN FETCH tk.keyword k") + List findAllWithKeywords(); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java index e584767..074fc30 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCollectionService; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherRecommendationGenerationService; import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherSyncReqDTO; import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; @@ -19,6 +20,7 @@ public class WeatherTriggerServiceImpl implements WeatherTriggerService{ private final WeatherDataCollectionService dataCollectionService; + private final WeatherRecommendationGenerationService recommendationGenerationService; public WeatherSyncResDTO.ManualTriggerResult triggerAsync(WeatherSyncReqDTO.ManualTrigger request) { LocalDateTime triggerTime = LocalDateTime.now(); @@ -57,20 +59,34 @@ private Object executeJob(WeatherSyncReqDTO.ManualTrigger request) { case "MEDIUM_TERM" -> dataCollectionService.collectMediumTermWeatherData( request.targetRegionIds(), LocalDate.now(), true); // ← forceExecution = true + case "RECOMMENDATION" -> { + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(6); + yield recommendationGenerationService.generateRecommendations( + request.targetRegionIds(), startDate, endDate, true, "일반"); + } + case "ALL" -> { LocalDateTime now = LocalDateTime.now(); LocalDate baseDate = now.toLocalDate(); String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + var shortResult = dataCollectionService.collectShortTermWeatherData( request.targetRegionIds(), baseDate, baseTime, true); var mediumResult = dataCollectionService.collectMediumTermWeatherData( request.targetRegionIds(), LocalDate.now(), true); + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(6); + var recommendationResult = recommendationGenerationService.generateRecommendations( + request.targetRegionIds(), startDate, endDate, true, "일반"); + yield WeatherSyncResDTO.CompleteSyncResult.builder() .shortTermResult(shortResult) .mediumTermResult(mediumResult) + .recommendationResult(recommendationResult) .overallStartTime(LocalDateTime.now()) .overallEndTime(LocalDateTime.now()) .overallDurationMs(0L) From f9f957909bdc69a3e54575296df74b1bed69cbed Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Fri, 18 Jul 2025 11:31:59 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=E2=9C=A8feat:=207=EC=9D=BC=EA=B0=84?= =?UTF-8?q?=EC=9D=98=20=EC=B6=94=EC=B2=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/WeatherController.java | 44 +++++++++++++++++-- src/main/resources/application-develop.yml | 2 +- src/main/resources/application.yml | 2 +- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java index 68d82f3..9bf2da3 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java @@ -1,21 +1,27 @@ package org.withtime.be.withtimebe.domain.weather.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.namul.api.payload.response.DefaultResponse; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherRecommendationGenerationService; +import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; import org.withtime.be.withtimebe.domain.weather.service.command.WeatherTriggerService; import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherSyncReqDTO; import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +import java.time.LocalDate; + @Slf4j @RestController @RequiredArgsConstructor @@ -24,6 +30,7 @@ public class WeatherController { private final WeatherTriggerService weatherTriggerService; + private final WeatherRecommendationGenerationService weatherRecommendationGenerationService; @PostMapping("/trigger") @Operation(summary = "수동 동기화 트리거 API by 지미 [Only Admin]", @@ -31,6 +38,7 @@ public class WeatherController { 관리자가 수동으로 다음 중 하나의 작업을 실행합니다: - SHORT_TERM: 단기 예보 데이터 수집 - MEDIUM_TERM: 중기 예보 데이터 수집 + - RECOMMENDATION: 날씨 기반 추천 생성 - ALL: 전체 동기화 작업 수행 --- 모든 작업은 비동기로 실행되며, 기존 데이터는 강제로 덮어씁니다. @@ -60,4 +68,32 @@ public DefaultResponse manualTrigger( return DefaultResponse.ok(response); } + + @GetMapping("/{regionId}/weekly") + @Operation( + summary = "지역별 주간 날씨 기반 추천 조회", + description = """ + 특정 지역의 7일치(오늘 기준) 날씨 데이터를 바탕으로 한 데이트 추천 정보를 제공합니다. + + - 날짜 범위: `startDate`부터 7일간 (startDate 포함) + - 추천 데이터는 날씨 분류 후 템플릿 기반으로 생성됩니다. + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "주간 추천 조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 파라미터 형식 (날짜 혹은 지역 ID 오류)"), + @ApiResponse(responseCode = "404", description = "해당 지역의 추천 정보가 존재하지 않음") + }) + public DefaultResponse getWeeklyRecommendation( + @Parameter(description = "지역 ID)", required = true) + @PathVariable @NotNull @Positive Long regionId, + + @Parameter(description = "조회 시작일 (YYYY-MM-DD)", required = true, example = "2025-07-17") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate) { + + log.info("주간 날씨 추천 조회 API 호출: regionId={}, startDate={}", regionId, startDate); + WeatherReqDTO.GetWeeklyRecommendation request = WeatherReqDTO.GetWeeklyRecommendation.of(regionId, startDate); + WeatherResDTO.WeeklyRecommendation response = weatherRecommendationGenerationService.getWeeklyRecommendation(request); + return DefaultResponse.ok(response); + } } diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index 8290dfd..ca7cabd 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -56,7 +56,7 @@ weather: # API 타임아웃 설정 timeout: connect: 5000 - read: 15000 + read: 20000 # 재시도 설정 retry: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 17a61d0..c3c4f33 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,7 +56,7 @@ weather: # API 타임아웃 설정 timeout: connect: 5000 - read: 15000 + read: 20000 # 재시도 설정 retry: From a08a9bdafe6d5d65219191ba640a130e2fa38217 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Fri, 18 Jul 2025 12:06:59 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=E2=9C=A8feat:=207=EC=9D=BC=EA=B0=84?= =?UTF-8?q?=EC=9D=98=20=EA=B0=95=EC=88=98=ED=99=95=EB=A5=A0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/WeatherController.java | 31 +++++++ ...eatherRecommendationGenerationService.java | 2 + ...erRecommendationGenerationServiceImpl.java | 81 +++++++++++++++++++ .../weather/dto/request/WeatherReqDTO.java | 27 +++++++ .../weather/dto/response/WeatherResDTO.java | 28 +++---- 5 files changed, 153 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java index 9bf2da3..9ebf242 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java @@ -96,4 +96,35 @@ public DefaultResponse getWeeklyRecommendati WeatherResDTO.WeeklyRecommendation response = weatherRecommendationGenerationService.getWeeklyRecommendation(request); return DefaultResponse.ok(response); } + + @GetMapping("/{regionId}/precipitation") + @Operation( + summary = "지역별 7일간 강수확률 조회", + description = """ + 특정 지역의 7일간 강수확률 정보만 간단하게 조회합니다. + + - 날짜 범위: `startDate`부터 7일간 (startDate 포함) + - 중기예보 데이터를 우선적으로 사용하고, 없을 경우 단기예보 데이터 사용 + - 각 날짜별 강수확률과 주간 평균, 경향 분석 제공 + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "강수확률 조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 파라미터 형식 (날짜 혹은 지역 ID 오류)"), + @ApiResponse(responseCode = "404", description = "해당 지역이 존재하지 않음") + }) + public DefaultResponse getWeeklyPrecipitation( + @Parameter(description = "지역 ID", required = true) + @PathVariable @NotNull @Positive Long regionId, + + @Parameter(description = "조회 시작일 (YYYY-MM-DD)", required = true, example = "2025-07-18") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate) { + + log.info("7일간 강수확률 조회 API 호출: regionId={}, startDate={}", regionId, startDate); + + WeatherReqDTO.GetWeeklyPrecipitation request = WeatherReqDTO.GetWeeklyPrecipitation.of(regionId, startDate); + WeatherResDTO.WeeklyPrecipitation response = weatherRecommendationGenerationService.getWeeklyPrecipitation(request); + + return DefaultResponse.ok(response); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java index 6d39e79..f941e56 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java @@ -13,4 +13,6 @@ WeatherSyncResDTO.RecommendationGenerationResult generateRecommendations( List regionIds, LocalDate startDate, LocalDate endDate, boolean forceRegenerate, String recommendationType); WeatherResDTO.WeeklyRecommendation getWeeklyRecommendation(WeatherReqDTO.GetWeeklyRecommendation request); + + WeatherResDTO.WeeklyPrecipitation getWeeklyPrecipitation(WeatherReqDTO.GetWeeklyPrecipitation request); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java index f0007a9..1d06e69 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java @@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional; import org.withtime.be.withtimebe.domain.weather.converter.WeatherConverter; import org.withtime.be.withtimebe.domain.weather.converter.WeatherSyncConverter; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherClassificationUtils; import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherRecommendationUtils; import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherReqDTO; @@ -106,6 +107,40 @@ public WeatherResDTO.WeeklyRecommendation getWeeklyRecommendation( request.startDate(), endDate); } + @Override + public WeatherResDTO.WeeklyPrecipitation getWeeklyPrecipitation( + WeatherReqDTO.GetWeeklyPrecipitation request) { + + log.info("주간 강수확률 조회 요청: regionId={}, startDate={}", + request.regionId(), request.startDate()); + + // 1. 지역 존재 확인 + Region region = validateRegionExists(request.regionId()); + + // 2. 7일간 강수확률 정보 조회 + LocalDate endDate = request.getEndDate(); + List dailyPrecipitations = new ArrayList<>(); + + // 3. 날짜별로 강수확률 조회 + for (LocalDate date = request.startDate(); !date.isAfter(endDate); date = date.plusDays(1)) { + WeatherResDTO.DailyPrecipitation dailyPrecip = getPrecipitationForDate(region, date); + dailyPrecipitations.add(dailyPrecip); + } + + log.info("주간 강수확률 조회 완료: regionId={}, 조회된 데이터 수={}", + request.regionId(), dailyPrecipitations.size()); + + return WeatherResDTO.WeeklyPrecipitation.builder() + .region(WeatherConverter.toRegionInfo(region)) + .startDate(request.startDate()) + .endDate(endDate) + .dailyPrecipitations(dailyPrecipitations) + .totalDays(dailyPrecipitations.size()) + .message(String.format("%s 지역의 %s부터 %s까지 7일간 강수확률 정보입니다.", + region.getName(), request.startDate(), endDate)) + .build(); + } + private void updateStats(List regionResults, Map weatherStats, Region region, RegionRecommendationResult result, long startTime) { @@ -211,6 +246,52 @@ private WeatherResDTO.WeatherClassificationResult classifyWeatherForDate(Region throw new WeatherException(WeatherErrorCode.WEATHER_DATA_NOT_FOUND); } + private WeatherResDTO.DailyPrecipitation getPrecipitationForDate(Region region, LocalDate date) { + try { + // 1. 중기예보 우선 조회 + List mediumTermData = + mediumTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); + + if (!mediumTermData.isEmpty()) { + RawMediumTermWeather data = WeatherClassificationUtils + .selectRepresentativeMediumTermData(mediumTermData, date); + + return WeatherResDTO.DailyPrecipitation.builder() + .forecastDate(date) + .precipitationProbability(data.getPrecipitationProbability()) + .build(); + } + + // 2. 중기예보 없으면 단기예보 사용 + List shortTermData = + shortTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); + + if (!shortTermData.isEmpty()) { + RawShortTermWeather data = WeatherClassificationUtils + .selectRepresentativeShortTermData(shortTermData, date); + + return WeatherResDTO.DailyPrecipitation.builder() + .forecastDate(date) + .precipitationProbability(data.getPrecipitationProbability()) + .build(); + } + + // 3. 데이터가 없는 경우 + log.warn("강수확률 데이터 없음: regionId={}, date={}", region.getId(), date); + return WeatherResDTO.DailyPrecipitation.builder() + .forecastDate(date) + .precipitationProbability(null) + .build(); + + } catch (Exception e) { + log.error("강수확률 조회 중 오류 발생: regionId={}, date={}", region.getId(), date, e); + return WeatherResDTO.DailyPrecipitation.builder() + .forecastDate(date) + .precipitationProbability(null) + .build(); + } + } + private Region validateRegionExists(Long regionId) { return regionRepository.findById(regionId) .orElseThrow(() -> { diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java index 348f7c8..3322f30 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java @@ -42,4 +42,31 @@ public LocalDate getEndDate() { } } + public record GetWeeklyPrecipitation( + @NotNull(message = "지역 ID는 필수 입력값입니다.") + @Positive(message = "지역 ID는 양수여야 합니다.") + Long regionId, + + @NotNull(message = "시작 날짜는 필수 입력값입니다.") + LocalDate startDate + ) { + public static GetWeeklyPrecipitation of(Long regionId, LocalDate startDate) { + if (startDate != null) { + LocalDate now = LocalDate.now(); + LocalDate minDate = now.minusDays(7); + LocalDate maxDate = now.plusDays(10); + + if (startDate.isBefore(minDate) || startDate.isAfter(maxDate)) { + throw new IllegalArgumentException( + "조회 가능한 시작 날짜 범위를 벗어났습니다. (7일 전 ~ 10일 후)"); + } + } + + return new GetWeeklyPrecipitation(regionId, startDate); + } + public LocalDate getEndDate() { + return startDate.plusDays(6); + } + } + } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java index 0e39403..bfc6c54 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java @@ -112,30 +112,26 @@ public record DailyWeatherRecommendation( } /** - * 날씨 추천 조회 실패 응답 (데이터 없음) + * 일별 강수확률 정보 */ @Builder - public record WeatherRecommendationNotFound( - Long regionId, - String regionName, - LocalDate requestedDate, - String message, - List suggestions // 대안 제안 (예: 가장 가까운 날짜의 데이터) + public record DailyPrecipitation( + LocalDate forecastDate, + Double precipitationProbability // 강수확률 (%) ) { } - /** - * 간단한 날씨 정보 (목록 조회용) + * 7일간 강수확률 정보 */ @Builder - public record WeatherRecommendationSummary( - Long recommendationId, - LocalDate forecastDate, - String regionName, - WeatherType weatherType, - String emoji, - String shortMessage // 메시지의 첫 50자 + public record WeeklyPrecipitation( + RegionInfo region, + LocalDate startDate, + LocalDate endDate, + List dailyPrecipitations, + int totalDays, + String message ) { } } From fd57619745dda150f75955bc592398d328a9a223 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Fri, 18 Jul 2025 12:14:37 +0900 Subject: [PATCH 07/12] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=207=EC=9D=BC?= =?UTF-8?q?=EA=B0=84=EC=9D=98=20=EA=B0=95=EC=88=98=ED=99=95=EB=A5=A0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20N+1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erRecommendationGenerationServiceImpl.java | 53 ++++++++++++------- .../RawMediumTermWeatherRepository.java | 15 ++++++ .../RawShortTermWeatherRepository.java | 15 ++++++ 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java index 1d06e69..f16cd53 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java @@ -22,6 +22,7 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.*; +import java.util.stream.Collectors; @Slf4j @Service @@ -116,19 +117,36 @@ public WeatherResDTO.WeeklyPrecipitation getWeeklyPrecipitation( // 1. 지역 존재 확인 Region region = validateRegionExists(request.regionId()); - - // 2. 7일간 강수확률 정보 조회 LocalDate endDate = request.getEndDate(); + + // 2. 7일치 데이터 한 번에 조회 + List mediumTermDataList = + mediumTermWeatherRepository.findByRegionIdAndForecastDateRange( + request.regionId(), request.startDate(), endDate); + + List shortTermDataList = + shortTermWeatherRepository.findByRegionIdAndForecastDateRange( + request.regionId(), request.startDate(), endDate); + + // 3. 날짜별로 그룹핑 (메모리에서 처리) + Map> mediumTermByDate = mediumTermDataList.stream() + .collect(Collectors.groupingBy(RawMediumTermWeather::getForecastDate)); + + Map> shortTermByDate = shortTermDataList.stream() + .collect(Collectors.groupingBy(RawShortTermWeather::getForecastDate)); + + // 4. 7일간 강수확률 정보 구성 List dailyPrecipitations = new ArrayList<>(); - // 3. 날짜별로 강수확률 조회 for (LocalDate date = request.startDate(); !date.isAfter(endDate); date = date.plusDays(1)) { - WeatherResDTO.DailyPrecipitation dailyPrecip = getPrecipitationForDate(region, date); + WeatherResDTO.DailyPrecipitation dailyPrecip = getPrecipitationForDateOptimized( + date, mediumTermByDate.get(date), shortTermByDate.get(date)); dailyPrecipitations.add(dailyPrecip); } - log.info("주간 강수확률 조회 완료: regionId={}, 조회된 데이터 수={}", - request.regionId(), dailyPrecipitations.size()); + log.info("주간 강수확률 조회 완료: regionId={}, 조회된 데이터 수={}, 중기예보 {}건, 단기예보 {}건", + request.regionId(), dailyPrecipitations.size(), + mediumTermDataList.size(), shortTermDataList.size()); return WeatherResDTO.WeeklyPrecipitation.builder() .region(WeatherConverter.toRegionInfo(region)) @@ -246,13 +264,13 @@ private WeatherResDTO.WeatherClassificationResult classifyWeatherForDate(Region throw new WeatherException(WeatherErrorCode.WEATHER_DATA_NOT_FOUND); } - private WeatherResDTO.DailyPrecipitation getPrecipitationForDate(Region region, LocalDate date) { + private WeatherResDTO.DailyPrecipitation getPrecipitationForDateOptimized( + LocalDate date, + List mediumTermData, + List shortTermData) { try { - // 1. 중기예보 우선 조회 - List mediumTermData = - mediumTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); - - if (!mediumTermData.isEmpty()) { + // 1. 중기예보 우선 사용 + if (mediumTermData != null && !mediumTermData.isEmpty()) { RawMediumTermWeather data = WeatherClassificationUtils .selectRepresentativeMediumTermData(mediumTermData, date); @@ -262,11 +280,8 @@ private WeatherResDTO.DailyPrecipitation getPrecipitationForDate(Region region, .build(); } - // 2. 중기예보 없으면 단기예보 사용 - List shortTermData = - shortTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); - - if (!shortTermData.isEmpty()) { + // 2. 단기예보 사용 + if (shortTermData != null && !shortTermData.isEmpty()) { RawShortTermWeather data = WeatherClassificationUtils .selectRepresentativeShortTermData(shortTermData, date); @@ -277,14 +292,14 @@ private WeatherResDTO.DailyPrecipitation getPrecipitationForDate(Region region, } // 3. 데이터가 없는 경우 - log.warn("강수확률 데이터 없음: regionId={}, date={}", region.getId(), date); + log.debug("강수확률 데이터 없음: date={}", date); return WeatherResDTO.DailyPrecipitation.builder() .forecastDate(date) .precipitationProbability(null) .build(); } catch (Exception e) { - log.error("강수확률 조회 중 오류 발생: regionId={}, date={}", region.getId(), date, e); + log.error("강수확률 조회 중 오류 발생: date={}", date, e); return WeatherResDTO.DailyPrecipitation.builder() .forecastDate(date) .precipitationProbability(null) diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java index 958a2d4..72d0c71 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java @@ -24,4 +24,19 @@ Optional findByRegionIdAndBaseDateAndForecastDate( List findLatestByRegionIdAndForecastDate( @Param("regionId") Long regionId, @Param("forecastDate") LocalDate forecastDate); + + /** + * 지역별 날짜 범위 중기예보 배치 조회 + */ + @Query(""" + SELECT rmtw FROM RawMediumTermWeather rmtw + WHERE rmtw.region.id = :regionId + AND rmtw.forecastDate BETWEEN :startDate AND :endDate + ORDER BY rmtw.forecastDate ASC, rmtw.baseDate DESC + """) + List findByRegionIdAndForecastDateRange( + @Param("regionId") Long regionId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java index de145e5..b6b1060 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java @@ -25,4 +25,19 @@ Optional findByRegionIdAndBaseDateAndBaseTimeAndForecastDat List findLatestByRegionIdAndForecastDate( @Param("regionId") Long regionId, @Param("forecastDate") LocalDate forecastDate); + + /** + * 지역별 날짜 범위 단기예보 배치 조회 + */ + @Query(""" + SELECT rstw FROM RawShortTermWeather rstw + WHERE rstw.region.id = :regionId + AND rstw.forecastDate BETWEEN :startDate AND :endDate + ORDER BY rstw.forecastDate ASC, rstw.baseDate DESC, rstw.baseTime DESC + """) + List findByRegionIdAndForecastDateRange( + @Param("regionId") Long regionId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); } From 45568bb4925bbbbac4106c613d21e288a0bf792f Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 19 Jul 2025 00:56:31 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=2000:02?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EC=A0=84=EC=97=90=20=EC=98=88=EB=B3=B4=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=EB=82=A0=EC=A7=9C=EC=9D=98=2023:00?= =?UTF-8?q?=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EA=B2=8C=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/scheduler/WeatherScheduler.java | 61 ++++++++++++++++--- .../weather/data/utils/WeatherDataHelper.java | 34 +++++++++++ .../command/WeatherTriggerServiceImpl.java | 39 ++++++++---- 3 files changed, 113 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java index 9f84242..bc84df4 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java @@ -6,6 +6,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCleanupService; import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCollectionService; import org.withtime.be.withtimebe.domain.weather.data.service.WeatherRecommendationGenerationService; import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; @@ -22,19 +23,21 @@ public class WeatherScheduler { private final WeatherDataCollectionService weatherDataCollectionService; private final WeatherRecommendationGenerationService weatherRecommendationGenerationService; + private final WeatherDataCleanupService dataCleanupService; // 스케줄러 실행 상태 volatile로 추척 private volatile boolean shortTermSyncRunning = false; private volatile boolean mediumTermSyncRunning = false; private volatile boolean shortTermRecommendationRunning = false; private volatile boolean mediumTermRecommendationRunning = false; + private volatile boolean cleanupRunning = false; /** * 단기 예보 데이터 수집 스케줄러 * 매 3시간마다 실행 (02:10, 05:10, 08:10, 11:10, 14:10, 17:10, 20:10, 23:10) * 기상청 발표 시각보다 10분 후에 실행하여 데이터 준비 시간 확보 */ - @Scheduled(cron = "${scheduler.weather.short-term-cron}") + @Scheduled(cron = "${scheduler.weather.real-short-term-cron}") @Async("weatherTaskExecutor") public void scheduledShortTermWeatherSync() { if (shortTermSyncRunning) { @@ -46,13 +49,14 @@ public void scheduledShortTermWeatherSync() { shortTermSyncRunning = true; log.info("단기 예보 동기화 스케줄러 시작"); - LocalDateTime now = LocalDateTime.now(); - LocalDate baseDate = now.toLocalDate(); - String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + // 올바른 base_date와 base_time 계산 + WeatherDataHelper.BaseDateTime baseDateTime = WeatherDataHelper.calculateBaseDateTime(); + + log.debug("계산된 base_date: {}, base_time: {}", baseDateTime.baseDate(), baseDateTime.baseTime()); // 모든 지역에 대해 동기화 실행 WeatherSyncResDTO.ShortTermSyncResult result = weatherDataCollectionService.collectShortTermWeatherData( - null, baseDate, baseTime, false); + null, baseDateTime.getBaseDateAsLocalDate(), baseDateTime.baseTime(), false); log.info("단기 예보 동기화 스케줄러 완료: 성공 {}/{} 지역, 신규 {} 건, 업데이트 {} 건", result.successfulRegions(), result.totalRegions(), @@ -69,7 +73,7 @@ public void scheduledShortTermWeatherSync() { * 중기 예보 데이터 수집 스케줄러 * 매 12시간마다 실행 (06:30, 18:30) */ - @Scheduled(cron = "${scheduler.weather.medium-term-cron}") + @Scheduled(cron = "${scheduler.weather.real-medium-term-cron}") @Async("weatherTaskExecutor") public void scheduledMediumTermWeatherSync() { if (mediumTermSyncRunning) { @@ -102,7 +106,7 @@ public void scheduledMediumTermWeatherSync() { * 단기예보 기반 추천 정보 생성 스케줄러 (0-3일, 실제 단기예보 데이터 범위) * 매 시간 5분에 실행 - 단기예보는 1시간마다 업데이트 */ - @Scheduled(cron = "${scheduler.weather.recommendation.short-term-cron}") + @Scheduled(cron = "${scheduler.weather.recommendation.real-short-term-cron}") @Async("weatherTaskExecutor") public void scheduledShortTermRecommendationGeneration() { if (shortTermRecommendationRunning) { @@ -137,7 +141,7 @@ public void scheduledShortTermRecommendationGeneration() { * 중기예보 기반 추천 정보 생성 스케줄러 (4-10일, 실제 중기예보 데이터 범위) * 매 6시간 30분에 실행 - 중기예보는 12시간마다 업데이트되므로 6시간마다 충분 */ - @Scheduled(cron = "${scheduler.weather.recommendation.medium-term-cron:0 30 0,6,12,18 * * *}") + @Scheduled(cron = "${scheduler.weather.recommendation.real-medium-term-cron:0 30 0,6,12,18 * * *}") @Async("weatherTaskExecutor") public void scheduledMediumTermRecommendationGeneration() { if (mediumTermRecommendationRunning) { @@ -167,4 +171,45 @@ public void scheduledMediumTermRecommendationGeneration() { mediumTermRecommendationRunning = false; } } + + /** + * 데이터 정리 스케줄러 + * 매일 새벽 3시에 실행 + */ + @Scheduled(cron = "${scheduler.weather.real-cleanup-cron}") + @Async("weatherTaskExecutor") + public void scheduledDataCleanup() { + if (cleanupRunning) { + log.warn("데이터 정리가 이미 실행 중입니다. 스킵합니다."); + return; + } + + try { + cleanupRunning = true; + log.info("데이터 정리 스케줄러 시작"); + + // 7일 이전 데이터 정리 + int retentionDays = 7; + WeatherSyncResDTO.CleanupResult result = dataCleanupService.cleanupOldWeatherData( + retentionDays, true, true, true, false); + + log.info("데이터 정리 스케줄러 완료: 보관기간 {}일, 처리시간 {}ms", + retentionDays, result.processingDurationMs()); + + if (result.shortTermStats() != null) { + log.info("단기예보 정리: {} 건 삭제", result.shortTermStats().recordsDeleted()); + } + if (result.mediumTermStats() != null) { + log.info("중기예보 정리: {} 건 삭제", result.mediumTermStats().recordsDeleted()); + } + if (result.recommendationStats() != null) { + log.info("추천정보 정리: {} 건 삭제", result.recommendationStats().recordsDeleted()); + } + + } catch (Exception e) { + log.error("데이터 정리 스케줄러 실행 중 오류 발생", e); + } finally { + cleanupRunning = false; + } + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java index 1f99e86..d53d30b 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java @@ -11,6 +11,9 @@ import org.withtime.be.withtimebe.domain.weather.repository.RawShortTermWeatherRepository; import org.withtime.be.withtimebe.domain.weather.repository.RegionRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Optional; @@ -37,6 +40,37 @@ public static String calculateNearestBaseTime(int currentHour) { return "2300"; } + public static BaseDateTime calculateBaseDateTime() { + LocalDateTime now = LocalDateTime.now(); + return calculateBaseDateTime(now); + } + + public static BaseDateTime calculateBaseDateTime(LocalDateTime targetTime) { + int currentHour = targetTime.getHour(); + + // base_time 계산 + String baseTime = calculateNearestBaseTime(currentHour); + + // base_date 계산 + LocalDate baseDate = targetTime.toLocalDate(); + + // 02:00 이전이면 전날 날짜 + 2300 사용 + if (currentHour < 2) { + baseDate = baseDate.minusDays(1); + baseTime = "2300"; + } + + String baseDateStr = baseDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + return new BaseDateTime(baseDateStr, baseTime); + } + + public record BaseDateTime(String baseDate, String baseTime) { + public LocalDate getBaseDateAsLocalDate() { + return LocalDate.parse(baseDate, DateTimeFormatter.ofPattern("yyyyMMdd")); + } + } + public static UpsertResult upsertShortTermWeatherData( List weatherDataList, boolean forceUpdate, diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java index 074fc30..a381a0d 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCleanupService; import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCollectionService; import org.withtime.be.withtimebe.domain.weather.data.service.WeatherRecommendationGenerationService; import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; @@ -21,6 +22,7 @@ public class WeatherTriggerServiceImpl implements WeatherTriggerService{ private final WeatherDataCollectionService dataCollectionService; private final WeatherRecommendationGenerationService recommendationGenerationService; + private final WeatherDataCleanupService dataCleanupService; public WeatherSyncResDTO.ManualTriggerResult triggerAsync(WeatherSyncReqDTO.ManualTrigger request) { LocalDateTime triggerTime = LocalDateTime.now(); @@ -49,15 +51,18 @@ private Object executeJob(WeatherSyncReqDTO.ManualTrigger request) { return switch (request.jobType()) { case "SHORT_TERM" -> { - LocalDateTime now = LocalDateTime.now(); - LocalDate baseDate = now.toLocalDate(); - String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + // 올바른 base_date와 base_time 계산 + WeatherDataHelper.BaseDateTime baseDateTime = WeatherDataHelper.calculateBaseDateTime(); + log.debug("SHORT_TERM 작업 - 계산된 base_date: {}, base_time: {}", + baseDateTime.baseDate(), baseDateTime.baseTime()); + yield dataCollectionService.collectShortTermWeatherData( - request.targetRegionIds(), baseDate, baseTime, true); // ← forceExecution = true + request.targetRegionIds(), baseDateTime.getBaseDateAsLocalDate(), + baseDateTime.baseTime(), true); // forceExecution = true } case "MEDIUM_TERM" -> dataCollectionService.collectMediumTermWeatherData( - request.targetRegionIds(), LocalDate.now(), true); // ← forceExecution = true + request.targetRegionIds(), LocalDate.now(), true); // forceExecution = true case "RECOMMENDATION" -> { LocalDate startDate = LocalDate.now(); @@ -66,27 +71,35 @@ private Object executeJob(WeatherSyncReqDTO.ManualTrigger request) { request.targetRegionIds(), startDate, endDate, true, "일반"); } - case "ALL" -> { - LocalDateTime now = LocalDateTime.now(); - LocalDate baseDate = now.toLocalDate(); - String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + case "CLEANUP" -> dataCleanupService.cleanupOldWeatherData( + 7, true, true, true, false); + case "ALL" -> { + // 올바른 base_date와 base_time 계산 + WeatherDataHelper.BaseDateTime baseDateTime = WeatherDataHelper.calculateBaseDateTime(); + log.debug("ALL 작업 - 계산된 base_date: {}, base_time: {}", + baseDateTime.baseDate(), baseDateTime.baseTime()); - var shortResult = dataCollectionService.collectShortTermWeatherData( - request.targetRegionIds(), baseDate, baseTime, true); + WeatherSyncResDTO.ShortTermSyncResult shortResult = dataCollectionService.collectShortTermWeatherData( + request.targetRegionIds(), baseDateTime.getBaseDateAsLocalDate(), + baseDateTime.baseTime(), true); - var mediumResult = dataCollectionService.collectMediumTermWeatherData( + WeatherSyncResDTO.MediumTermSyncResult mediumResult = dataCollectionService.collectMediumTermWeatherData( request.targetRegionIds(), LocalDate.now(), true); LocalDate startDate = LocalDate.now(); LocalDate endDate = startDate.plusDays(6); - var recommendationResult = recommendationGenerationService.generateRecommendations( + WeatherSyncResDTO.RecommendationGenerationResult recommendationResult = recommendationGenerationService.generateRecommendations( request.targetRegionIds(), startDate, endDate, true, "일반"); + WeatherSyncResDTO.CleanupResult cleanupResult = dataCleanupService.cleanupOldWeatherData( + 7, true, true, true, false); + yield WeatherSyncResDTO.CompleteSyncResult.builder() .shortTermResult(shortResult) .mediumTermResult(mediumResult) .recommendationResult(recommendationResult) + .cleanupResult(cleanupResult) .overallStartTime(LocalDateTime.now()) .overallEndTime(LocalDateTime.now()) .overallDurationMs(0L) From 9a0e5f5b707d2013e353235b49cc2f1faa1b5163 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 19 Jul 2025 01:36:15 +0900 Subject: [PATCH 09/12] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=83=9D=EC=84=B1=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=82=AC=EC=9A=A9=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erRecommendationGenerationServiceImpl.java | 92 +++++++++++++++++-- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java index f16cd53..c3c7e54 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java @@ -249,19 +249,83 @@ private RecommendationResult generateRecommendationForDate(Region region, LocalD } private WeatherResDTO.WeatherClassificationResult classifyWeatherForDate(Region region, LocalDate date) { - // 1. 중기예보 우선 조회 + + // 1. 두 데이터 소스 모두 조회 List mediumTermData = mediumTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); - if (!mediumTermData.isEmpty()) { - return classificationService.classifyMediumTermWeather(mediumTermData, region.getId(), date); + List shortTermData = shortTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); + + // 2. 스마트 우선순위 결정 + DataSourceDecision decision = determineOptimalDataSource(date, shortTermData, mediumTermData); + + // 3. 결정된 데이터 소스 사용 + return switch (decision.source()) { + case SHORT_TERM -> { + log.debug("단기예보 사용: {} (이유: {})", date, decision.reason()); + yield classificationService.classifyShortTermWeatherWithCentralTemp(shortTermData, region.getId(), date); + } + case MEDIUM_TERM -> { + log.debug("중기예보 사용: {} (이유: {})", date, decision.reason()); + yield classificationService.classifyMediumTermWeather(mediumTermData, region.getId(), date); + } + case NONE -> { + log.error("사용 가능한 날씨 데이터 없음: {}", date); + throw new WeatherException(WeatherErrorCode.WEATHER_DATA_NOT_FOUND); + } + }; + } + + private DataSourceDecision determineOptimalDataSource(LocalDate targetDate, + List shortTermData, + List mediumTermData) { + + LocalDate today = LocalDate.now(); + long daysFromToday = ChronoUnit.DAYS.between(today, targetDate); + + // 1. 데이터 존재 여부 확인 + boolean hasShortTerm = shortTermData != null && !shortTermData.isEmpty(); + boolean hasMediumTerm = mediumTermData != null && !mediumTermData.isEmpty(); + + if (!hasShortTerm && !hasMediumTerm) { + return new DataSourceDecision(DataSource.NONE, "데이터 없음"); } - // 2. 중기예보 없으면 단기예보 사용 (온도 중앙값 적용) - List shortTermData = shortTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); - if (!shortTermData.isEmpty()) { - return classificationService.classifyShortTermWeatherWithCentralTemp(shortTermData, region.getId(), date); + // 2. 단기예보 우선 범위 (0~3일): 단기예보가 더 정확 + if (daysFromToday >= 0 && daysFromToday <= 3) { + if (hasShortTerm) { + // 단기예보 데이터 품질 검사 + if (shortTermData.size() >= 8) { + return new DataSourceDecision(DataSource.SHORT_TERM, + String.format("단기예보 범위 내(%d일 후), 충분한 데이터(%d개)", daysFromToday, shortTermData.size())); + } else { + log.warn("단기예보 데이터 부족: {}일 후, {}개 데이터", daysFromToday, shortTermData.size()); + return hasMediumTerm ? + new DataSourceDecision(DataSource.MEDIUM_TERM, "단기예보 데이터 부족으로 중기예보 사용") : + new DataSourceDecision(DataSource.SHORT_TERM, "단기예보 데이터 부족하지만 중기예보 없음"); + } + } else { + return new DataSourceDecision(DataSource.MEDIUM_TERM, "단기예보 없음"); + } + } + + // 3. 중기예보 우선 범위 (4일~): 중기예보가 적절 + else if (daysFromToday >= 4) { + if (hasMediumTerm) { + return new DataSourceDecision(DataSource.MEDIUM_TERM, + String.format("중기예보 범위 내(%d일 후)", daysFromToday)); + } else { + return new DataSourceDecision(DataSource.SHORT_TERM, "중기예보 없어서 단기예보 사용"); + } } - throw new WeatherException(WeatherErrorCode.WEATHER_DATA_NOT_FOUND); + // 4. 과거 날짜: 단기예보 우선 (더 정확했던 데이터) + else { + if (hasShortTerm) { + return new DataSourceDecision(DataSource.SHORT_TERM, + String.format("과거 날짜(%d일 전), 단기예보 우선", Math.abs(daysFromToday))); + } else { + return new DataSourceDecision(DataSource.MEDIUM_TERM, "과거 날짜, 단기예보 없음"); + } + } } private WeatherResDTO.DailyPrecipitation getPrecipitationForDateOptimized( @@ -320,4 +384,16 @@ private record RegionRecommendationResult(int recommendationsGenerated, int newR Map weatherTypeStats) {} private record RecommendationResult(WeatherType weatherType, boolean isNew) {} + + /** + * 데이터 소스 결정 결과 + */ + private record DataSourceDecision(DataSource source, String reason) {} + + /** + * 데이터 소스 열거형 + */ + private enum DataSource { + SHORT_TERM, MEDIUM_TERM, NONE + } } From 9ac85da11f72934bb8a4c3893b4008861f48e5c2 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 19 Jul 2025 01:44:29 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=E2=9C=A8feat:=20=EC=98=A4=EB=9E=98?= =?UTF-8?q?=EB=90=9C=20=EB=82=A0=EC=94=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20API=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/WeatherController.java | 1 + .../service/WeatherDataCleanupService.java | 9 + .../WeatherDataCleanupServiceImpl.java | 241 ++++++++++++++++++ .../dto/response/WeatherSyncResDTO.java | 32 +++ .../DailyRecommendationRepository.java | 24 ++ .../RawMediumTermWeatherRepository.java | 24 ++ .../RawShortTermWeatherRepository.java | 26 ++ src/main/resources/application-develop.yml | 10 +- 8 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupService.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupServiceImpl.java diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java index 9ebf242..4a2a9e2 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java @@ -39,6 +39,7 @@ public class WeatherController { - SHORT_TERM: 단기 예보 데이터 수집 - MEDIUM_TERM: 중기 예보 데이터 수집 - RECOMMENDATION: 날씨 기반 추천 생성 + - CLEANUP: 오래된 날씨 데이터 삭제 - ALL: 전체 동기화 작업 수행 --- 모든 작업은 비동기로 실행되며, 기존 데이터는 강제로 덮어씁니다. diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupService.java new file mode 100644 index 0000000..60fa107 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupService.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; + +public interface WeatherDataCleanupService { + + WeatherSyncResDTO.CleanupResult cleanupOldWeatherData( + Integer retentionDays, boolean cleanupShortTerm, boolean cleanupMediumTerm, boolean cleanupRecommendations, boolean dryRun); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupServiceImpl.java new file mode 100644 index 0000000..2aaeb55 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupServiceImpl.java @@ -0,0 +1,241 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +import org.withtime.be.withtimebe.domain.weather.repository.DailyRecommendationRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RawMediumTermWeatherRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RawShortTermWeatherRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherDataCleanupServiceImpl implements WeatherDataCleanupService{ + + private final RawShortTermWeatherRepository shortTermWeatherRepository; + private final RawMediumTermWeatherRepository mediumTermWeatherRepository; + private final DailyRecommendationRepository dailyRecommendationRepository; + + /** + * 오래된 날씨 데이터 정리 + */ + @Override + @Transactional + public WeatherSyncResDTO.CleanupResult cleanupOldWeatherData( + Integer retentionDays, boolean cleanupShortTerm, boolean cleanupMediumTerm, + boolean cleanupRecommendations, boolean dryRun) { + + LocalDateTime startTime = LocalDateTime.now(); + log.info("데이터 정리 시작: retentionDays={}, dryRun={}, cleanupShortTerm={}, cleanupMediumTerm={}, cleanupRecommendations={}", + retentionDays, dryRun, cleanupShortTerm, cleanupMediumTerm, cleanupRecommendations); + + LocalDate cutoffDate = LocalDate.now().minusDays(retentionDays); + List errorMessages = new ArrayList<>(); + + try { + // 단기 예보 데이터 정리 + WeatherSyncResDTO.CleanupStats shortTermStats = null; + if (cleanupShortTerm) { + shortTermStats = cleanupShortTermData(cutoffDate, dryRun); + } + + // 중기 예보 데이터 정리 + WeatherSyncResDTO.CleanupStats mediumTermStats = null; + if (cleanupMediumTerm) { + mediumTermStats = cleanupMediumTermData(cutoffDate, dryRun); + } + + // 추천 정보 정리 + WeatherSyncResDTO.CleanupStats recommendationStats = null; + if (cleanupRecommendations) { + recommendationStats = cleanupRecommendationData(cutoffDate, dryRun); + } + + LocalDateTime endTime = LocalDateTime.now(); + long durationMs = java.time.Duration.between(startTime, endTime).toMillis(); + + return WeatherSyncResDTO.CleanupResult.builder() + .dryRun(dryRun) + .retentionDays(retentionDays) + .cutoffDate(cutoffDate) + .shortTermStats(shortTermStats) + .mediumTermStats(mediumTermStats) + .recommendationStats(recommendationStats) + .processingStartTime(startTime) + .processingEndTime(endTime) + .processingDurationMs(durationMs) + .errorMessages(errorMessages) + .message("데이터 정리 성공") + .build(); + + } catch (Exception e) { + log.error("데이터 정리 중 오류 발생", e); + errorMessages.add("데이터 정리 실패: " + e.getMessage()); + + LocalDateTime endTime = LocalDateTime.now(); + long durationMs = java.time.Duration.between(startTime, endTime).toMillis(); + + return WeatherSyncResDTO.CleanupResult.builder() + .dryRun(dryRun) + .retentionDays(retentionDays) + .cutoffDate(cutoffDate) + .shortTermStats(null) + .mediumTermStats(null) + .recommendationStats(null) + .processingStartTime(startTime) + .processingEndTime(endTime) + .processingDurationMs(durationMs) + .errorMessages(errorMessages) + .message("데이터 정리 실패") + .build(); + } + } + + /** + * 단기 예보 데이터 정리 + */ + private WeatherSyncResDTO.CleanupStats cleanupShortTermData(LocalDate cutoffDate, boolean dryRun) { + log.debug("단기 예보 데이터 정리: cutoffDate={}, dryRun={}", cutoffDate, dryRun); + + try { + // 삭제 대상 레코드 수 정확히 조회 + long recordsFound = shortTermWeatherRepository.countOldData(cutoffDate); + + // 상세 통계 정보 조회 (로깅용) + Object[] statistics = shortTermWeatherRepository.getOldDataStatistics(cutoffDate); + if (statistics != null && statistics.length == 3 && statistics[2] != null) { + LocalDate oldestDate = (LocalDate) statistics[0]; + LocalDate newestDate = (LocalDate) statistics[1]; + Long count = (Long) statistics[2]; + log.debug("단기예보 삭제 대상 통계: 최오래된날짜={}, 최신날짜={}, 총개수={}", + oldestDate, newestDate, count); + } + + int recordsDeleted = 0; + if (!dryRun && recordsFound > 0) { + // 실제 삭제 실행 및 삭제된 레코드 수 반환 + recordsDeleted = shortTermWeatherRepository.deleteOldData(cutoffDate); + log.info("단기예보 데이터 삭제 완료: 예상 {}, 실제 삭제 {}", recordsFound, recordsDeleted); + } else if (dryRun) { + log.info("단기예보 데이터 정리 시뮬레이션: {} 건이 삭제 대상입니다", recordsFound); + } + + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("단기예보") + .executed(!dryRun) + .recordsFound((int) recordsFound) + .recordsDeleted(recordsDeleted) + .build(); + + } catch (Exception e) { + log.error("단기 예보 데이터 정리 실패", e); + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("단기예보") + .executed(false) + .recordsFound(0) + .recordsDeleted(0) + .build(); + } + } + + /** + * 중기 예보 데이터 정리 + */ + private WeatherSyncResDTO.CleanupStats cleanupMediumTermData(LocalDate cutoffDate, boolean dryRun) { + log.debug("중기 예보 데이터 정리: cutoffDate={}, dryRun={}", cutoffDate, dryRun); + + try { + // 삭제 대상 레코드 수 정확히 조회 + long recordsFound = mediumTermWeatherRepository.countOldData(cutoffDate); + + // 상세 통계 정보 조회 (로깅용) + Object[] statistics = mediumTermWeatherRepository.getOldDataStatistics(cutoffDate); + if (statistics != null && statistics.length == 3 && statistics[2] != null) { + LocalDate oldestDate = (LocalDate) statistics[0]; + LocalDate newestDate = (LocalDate) statistics[1]; + Long count = (Long) statistics[2]; + log.debug("중기예보 삭제 대상 통계: 최오래된날짜={}, 최신날짜={}, 총개수={}", + oldestDate, newestDate, count); + } + + int recordsDeleted = 0; + if (!dryRun && recordsFound > 0) { + // 실제 삭제 실행 및 삭제된 레코드 수 반환 + recordsDeleted = mediumTermWeatherRepository.deleteOldData(cutoffDate); + log.info("중기예보 데이터 삭제 완료: 예상 {}, 실제 삭제 {}", recordsFound, recordsDeleted); + } else if (dryRun) { + log.info("중기예보 데이터 정리 시뮬레이션: {} 건이 삭제 대상입니다", recordsFound); + } + + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("중기예보") + .executed(!dryRun) + .recordsFound((int) recordsFound) + .recordsDeleted(recordsDeleted) + .build(); + + } catch (Exception e) { + log.error("중기 예보 데이터 정리 실패", e); + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("중기예보") + .executed(false) + .recordsFound(0) + .recordsDeleted(0) + .build(); + } + } + + /** + * 추천 정보 데이터 정리 + */ + private WeatherSyncResDTO.CleanupStats cleanupRecommendationData(LocalDate cutoffDate, boolean dryRun) { + log.debug("추천 정보 데이터 정리: cutoffDate={}, dryRun={}", cutoffDate, dryRun); + + try { + // 삭제 대상 레코드 수 정확히 조회 + long recordsFound = dailyRecommendationRepository.countOldRecommendations(cutoffDate); + + // 상세 통계 정보 조회 (로깅용) + Object[] statistics = dailyRecommendationRepository.getOldRecommendationStatistics(cutoffDate); + if (statistics != null && statistics.length == 3 && statistics[2] != null) { + LocalDate oldestDate = (LocalDate) statistics[0]; + LocalDate newestDate = (LocalDate) statistics[1]; + Long count = (Long) statistics[2]; + log.debug("추천정보 삭제 대상 통계: 최오래된날짜={}, 최신날짜={}, 총개수={}", + oldestDate, newestDate, count); + } + + int recordsDeleted = 0; + if (!dryRun && recordsFound > 0) { + // 실제 삭제 실행 및 삭제된 레코드 수 반환 + recordsDeleted = dailyRecommendationRepository.deleteOldRecommendations(cutoffDate); + log.info("추천정보 데이터 삭제 완료: 예상 {}, 실제 삭제 {}", recordsFound, recordsDeleted); + } else if (dryRun) { + log.info("추천정보 데이터 정리 시뮬레이션: {} 건이 삭제 대상입니다", recordsFound); + } + + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("추천정보") + .executed(!dryRun) + .recordsFound((int) recordsFound) + .recordsDeleted(recordsDeleted) + .build(); + + } catch (Exception e) { + log.error("추천 정보 데이터 정리 실패", e); + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("추천정보") + .executed(false) + .recordsFound(0) + .recordsDeleted(0) + .build(); + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java index 09c46b5..34289e7 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java @@ -91,6 +91,7 @@ public record CompleteSyncResult( ShortTermSyncResult shortTermResult, // 단기 예보 결과 MediumTermSyncResult mediumTermResult, // 중기 예보 결과 RecommendationGenerationResult recommendationResult, // 추천 생성 결과 + CleanupResult cleanupResult, // 정리 작업 결과 LocalDateTime overallStartTime, // 전체 시작 시간 LocalDateTime overallEndTime, // 전체 종료 시간 long overallDurationMs, // 전체 소요 시간 (밀리초) @@ -155,4 +156,35 @@ public record RecommendationGenerationResult( ) { } + /** + * 정리 작업별 통계 + */ + @Builder + public record CleanupStats( + String dataType, // 데이터 타입 (단기/중기/추천) + boolean executed, // 실행 여부 + int recordsFound, // 발견된 레코드 수 + int recordsDeleted // 삭제된 레코드 수 + ) { + } + + /** + * 데이터 정리 결과 DTO + */ + @Builder + public record CleanupResult( + boolean dryRun, // Dry run 여부 + int retentionDays, // 보관 기간 + LocalDate cutoffDate, // 삭제 기준 날짜 + CleanupStats shortTermStats, // 단기 예보 정리 결과 + CleanupStats mediumTermStats, // 중기 예보 정리 결과 + CleanupStats recommendationStats, // 추천 정보 정리 결과 + LocalDateTime processingStartTime, // 처리 시작 시간 + LocalDateTime processingEndTime, // 처리 종료 시간 + long processingDurationMs, // 처리 소요 시간 (밀리초) + List errorMessages, // 오류 메시지들 + String message // 전체 결과 메시지 + ) { + } + } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java index 893423e..b1396de 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java @@ -1,6 +1,7 @@ package org.withtime.be.withtimebe.domain.weather.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.withtime.be.withtimebe.domain.weather.entity.DailyRecommendation; @@ -41,4 +42,27 @@ List findWeeklyRecommendations( @Param("regionId") Long regionId, @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); + + /** + * 오래된 추천 데이터 개수 조회 (삭제 대상 확인용) + * @param cutoffDate 기준 날짜 (이 날짜 이전 데이터가 삭제 대상) + * @return 삭제 대상 레코드 수 + */ + @Query("SELECT COUNT(dr) FROM DailyRecommendation dr WHERE dr.forecastDate < :cutoffDate") + long countOldRecommendations(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 추천 데이터 상세 정보 조회 (통계용) + */ + @Query("SELECT MIN(dr.forecastDate), MAX(dr.forecastDate), COUNT(dr) " + + "FROM DailyRecommendation dr WHERE dr.forecastDate < :cutoffDate") + Object[] getOldRecommendationStatistics(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 추천 데이터 삭제용 (cutoffDate 이전) + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM DailyRecommendation dr WHERE dr.forecastDate < :cutoffDate") + int deleteOldRecommendations(@Param("cutoffDate") LocalDate cutoffDate); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java index 72d0c71..8650aed 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java @@ -1,6 +1,7 @@ package org.withtime.be.withtimebe.domain.weather.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; @@ -39,4 +40,27 @@ List findByRegionIdAndForecastDateRange( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate ); + + /** + * 오래된 중기 예보 데이터 개수 조회 (삭제 대상 확인용) + * @param cutoffDate 기준 날짜 (이 날짜 이전 예보 대상 데이터가 삭제 대상) + * @return 삭제 대상 레코드 수 + */ + @Query("SELECT COUNT(rmtw) FROM RawMediumTermWeather rmtw WHERE rmtw.forecastDate < :cutoffDate") + long countOldData(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 중기 예보 데이터 상세 정보 조회 (통계용) + */ + @Query("SELECT MIN(rmtw.forecastDate), MAX(rmtw.forecastDate), COUNT(rmtw) " + + "FROM RawMediumTermWeather rmtw WHERE rmtw.forecastDate < :cutoffDate") + Object[] getOldDataStatistics(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 중기 예보 데이터 삭제 (cutoffDate 이전 예보 대상 데이터) + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM RawMediumTermWeather rmtw WHERE rmtw.forecastDate < :cutoffDate") + int deleteOldData(@Param("cutoffDate") LocalDate cutoffDate); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java index b6b1060..f1c4477 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java @@ -1,6 +1,7 @@ package org.withtime.be.withtimebe.domain.weather.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; @@ -40,4 +41,29 @@ List findByRegionIdAndForecastDateRange( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate ); + + /** + * 오래된 단기 예보 데이터 개수 조회 (삭제 대상 확인용) + * @param cutoffDate 기준 날짜 (이 날짜 이전 예보 대상 데이터가 삭제 대상) + * @return 삭제 대상 레코드 수 + */ + @Query("SELECT COUNT(rstw) FROM RawShortTermWeather rstw WHERE rstw.forecastDate < :cutoffDate") + long countOldData(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 단기 예보 데이터 상세 정보 조회 (통계용) + * @param cutoffDate 기준 날짜 + * @return [최오래된예보날짜, 최신예보날짜, 레코드수] + */ + @Query("SELECT MIN(rstw.forecastDate), MAX(rstw.forecastDate), COUNT(rstw) " + + "FROM RawShortTermWeather rstw WHERE rstw.forecastDate < :cutoffDate") + Object[] getOldDataStatistics(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 단기 예보 데이터 삭제 (cutoffDate 이전 예보 대상 데이터) + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM RawShortTermWeather rstw WHERE rstw.forecastDate < :cutoffDate") + int deleteOldData(@Param("cutoffDate") LocalDate cutoffDate); } diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index ca7cabd..f8b7060 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -69,14 +69,14 @@ scheduler: enabled: true # 스케줄러 활성화 여부 # 데이터 수집 스케줄 - short-term-cron: "10 2,5,8,11,14,17,20,23 * * * *" # 단기예보 수집 (매 3시간 10분) - medium-term-cron: "30 6,18 * * * *" # 중기예보 수집 (매 12시간 30분) - cleanup-cron: "0 0 3 * * *" # 데이터 정리 (매일 새벽 3시) + real-short-term-cron: "10 2,5,8,11,14,17,20,23 * * * *" # 단기예보 수집 (매 3시간 10분) + real-medium-term-cron: "30 6,18 * * * *" # 중기예보 수집 (매 12시간 30분) + real-cleanup-cron: "0 0 3 * * *" # 데이터 정리 (매일 새벽 3시) # 추천 정보 생성 스케줄 recommendation: - short-term-cron: "0 5 * * * *" # 단기예보 추천 (매 시간 5분) - 0-2일 - medium-term-cron: "0 30 */6 * * *" # 중기예보 추천 (6시간마다 30분) - 3-6일 + real-short-term-cron: "0 5 * * * *" # 단기예보 추천 (매 시간 5분) - 0-2일 + real-medium-term-cron: "0 30 */6 * * *" # 중기예보 추천 (6시간마다 30분) - 3-6일 # 데이터 보관 기간 설정 retention: From b749fe9c57b2eb12444bed72f79615a3ecccdf66 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 19 Jul 2025 10:32:45 +0900 Subject: [PATCH 11/12] =?UTF-8?q?=E2=9C=A8feat:=20Application=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=8B=9C=20=EB=82=A0=EC=94=A8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/withtimebe/WithTimeBeApplication.java | 2 + .../data/scheduler/WeatherScheduler.java | 66 +++++++++++++++++-- src/main/resources/application-develop.yml | 10 +-- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java b/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java index 11234d3..2cf51ed 100644 --- a/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java +++ b/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @EnableJpaAuditing @SpringBootApplication public class WithTimeBeApplication { diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java index bc84df4..d48cfd5 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java @@ -13,7 +13,7 @@ import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; @Slf4j @Component @@ -37,7 +37,7 @@ public class WeatherScheduler { * 매 3시간마다 실행 (02:10, 05:10, 08:10, 11:10, 14:10, 17:10, 20:10, 23:10) * 기상청 발표 시각보다 10분 후에 실행하여 데이터 준비 시간 확보 */ - @Scheduled(cron = "${scheduler.weather.real-short-term-cron}") + @Scheduled(cron = "${scheduler.weather.short-term-cron}") @Async("weatherTaskExecutor") public void scheduledShortTermWeatherSync() { if (shortTermSyncRunning) { @@ -73,7 +73,7 @@ public void scheduledShortTermWeatherSync() { * 중기 예보 데이터 수집 스케줄러 * 매 12시간마다 실행 (06:30, 18:30) */ - @Scheduled(cron = "${scheduler.weather.real-medium-term-cron}") + @Scheduled(cron = "${scheduler.weather.medium-term-cron}") @Async("weatherTaskExecutor") public void scheduledMediumTermWeatherSync() { if (mediumTermSyncRunning) { @@ -106,7 +106,7 @@ public void scheduledMediumTermWeatherSync() { * 단기예보 기반 추천 정보 생성 스케줄러 (0-3일, 실제 단기예보 데이터 범위) * 매 시간 5분에 실행 - 단기예보는 1시간마다 업데이트 */ - @Scheduled(cron = "${scheduler.weather.recommendation.real-short-term-cron}") + @Scheduled(cron = "${scheduler.weather.recommendation.short-term-cron}") @Async("weatherTaskExecutor") public void scheduledShortTermRecommendationGeneration() { if (shortTermRecommendationRunning) { @@ -141,7 +141,7 @@ public void scheduledShortTermRecommendationGeneration() { * 중기예보 기반 추천 정보 생성 스케줄러 (4-10일, 실제 중기예보 데이터 범위) * 매 6시간 30분에 실행 - 중기예보는 12시간마다 업데이트되므로 6시간마다 충분 */ - @Scheduled(cron = "${scheduler.weather.recommendation.real-medium-term-cron:0 30 0,6,12,18 * * *}") + @Scheduled(cron = "${scheduler.weather.recommendation.medium-term-cron}") @Async("weatherTaskExecutor") public void scheduledMediumTermRecommendationGeneration() { if (mediumTermRecommendationRunning) { @@ -176,7 +176,7 @@ public void scheduledMediumTermRecommendationGeneration() { * 데이터 정리 스케줄러 * 매일 새벽 3시에 실행 */ - @Scheduled(cron = "${scheduler.weather.real-cleanup-cron}") + @Scheduled(cron = "${scheduler.weather.cleanup-cron}") @Async("weatherTaskExecutor") public void scheduledDataCleanup() { if (cleanupRunning) { @@ -212,4 +212,58 @@ public void scheduledDataCleanup() { cleanupRunning = false; } } + + /** + * 애플리케이션 시작 시 초기 데이터 동기화 + * 서버 재시작 후 최신 데이터 확보 + */ + @Scheduled(initialDelay = 60000, fixedDelay = Long.MAX_VALUE) // 1분 후 1회 실행 + @Async("weatherTaskExecutor") + public void initialDataSync() { + log.info("애플리케이션 시작 후 초기 데이터 동기화 시작"); + + try { + // 올바른 base_date와 base_time 계산 + WeatherDataHelper.BaseDateTime baseDateTime = WeatherDataHelper.calculateBaseDateTime(); + + CompletableFuture shortTermFuture = CompletableFuture.runAsync(() -> { + try { + weatherDataCollectionService.collectShortTermWeatherData( + null, baseDateTime.getBaseDateAsLocalDate(), baseDateTime.baseTime(), false); + log.info("초기 단기 예보 동기화 완료"); + } catch (Exception e) { + log.error("초기 단기 예보 동기화 실패", e); + } + }); + + // 중기 예보 동기화 + CompletableFuture mediumTermFuture = CompletableFuture.runAsync(() -> { + try { + weatherDataCollectionService.collectMediumTermWeatherData(null, LocalDate.now(), false); + log.info("초기 중기 예보 동기화 완료"); + } catch (Exception e) { + log.error("초기 중기 예보 동기화 실패", e); + } + }); + + // 두 동기화 작업 완료 후 추천 정보 생성 + CompletableFuture.allOf(shortTermFuture, mediumTermFuture).thenRun(() -> { + try { + // 전체 기간 추천 정보 생성 (실제 데이터 존재 여부 기반) + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(6); + weatherRecommendationGenerationService.generateRecommendations( + null, startDate, endDate, false, "초기동기화"); + log.info("초기 추천 정보 생성 완료"); + } catch (Exception e) { + log.error("초기 추천 정보 생성 실패", e); + } + }); + + log.info("초기 데이터 동기화 작업이 시작되었습니다."); + + } catch (Exception e) { + log.error("초기 데이터 동기화 중 오류 발생", e); + } + } } diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index f8b7060..ca7cabd 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -69,14 +69,14 @@ scheduler: enabled: true # 스케줄러 활성화 여부 # 데이터 수집 스케줄 - real-short-term-cron: "10 2,5,8,11,14,17,20,23 * * * *" # 단기예보 수집 (매 3시간 10분) - real-medium-term-cron: "30 6,18 * * * *" # 중기예보 수집 (매 12시간 30분) - real-cleanup-cron: "0 0 3 * * *" # 데이터 정리 (매일 새벽 3시) + short-term-cron: "10 2,5,8,11,14,17,20,23 * * * *" # 단기예보 수집 (매 3시간 10분) + medium-term-cron: "30 6,18 * * * *" # 중기예보 수집 (매 12시간 30분) + cleanup-cron: "0 0 3 * * *" # 데이터 정리 (매일 새벽 3시) # 추천 정보 생성 스케줄 recommendation: - real-short-term-cron: "0 5 * * * *" # 단기예보 추천 (매 시간 5분) - 0-2일 - real-medium-term-cron: "0 30 */6 * * *" # 중기예보 추천 (6시간마다 30분) - 3-6일 + short-term-cron: "0 5 * * * *" # 단기예보 추천 (매 시간 5분) - 0-2일 + medium-term-cron: "0 30 */6 * * *" # 중기예보 추천 (6시간마다 30분) - 3-6일 # 데이터 보관 기간 설정 retention: From a0d67b690b57ebef4716990e3e6b0b1fe447bfc3 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 19 Jul 2025 14:59:37 +0900 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=9A=80chore:=20git=20conflict=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B02?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../auth/controller/AuthController.java | 9 +- .../auth/controller/OAuth2Controller.java | 55 +++++++++ .../auth/converter/OAuth2Converter.java | 41 +++++++ .../auth/dto/request/AuthRequestDTO.java | 3 +- .../auth/dto/response/OAuth2ResponseDTO.java | 26 +++++ .../domain/auth/factory/OAuth2UserLoader.java | 8 ++ .../auth/factory/OAuth2UserLoaderFactory.java | 29 +++++ .../support/AbstractOAuth2UserLoader.java | 108 ++++++++++++++++++ .../factory/support/GoogleUserLoader.java | 39 +++++++ .../auth/factory/support/KakaoUserLoader.java | 40 +++++++ .../auth/factory/support/NaverUserLoader.java | 38 ++++++ .../support/dto/GoogleOAuth2ResponseDTO.java | 23 ++++ .../support/dto/KakaoOAuth2ResponseDTO.java | 53 +++++++++ .../support/dto/NaverOAuth2ResponseDTO.java | 24 ++++ .../command/AuthCommandServiceImpl.java | 21 ++-- .../service/command/OAuth2CommandService.java | 9 ++ .../command/OAuth2CommandServiceImpl.java | 69 +++++++++++ .../member/converter/SocialConverter.java | 22 ++++ .../domain/member/entity/Member.java | 1 - .../domain/member/entity/Social.java | 4 + .../member/entity/enums/ProviderType.java | 5 - .../member/repository/SocialRepository.java | 11 ++ .../query/NoticeQueryController.java | 21 ++-- .../converter/NoticeCategoryConverter.java | 17 +++ .../notice/converter/NoticeConverter.java | 50 +------- .../notice/dto/request/NoticeRequestDTO.java | 23 +--- .../dto/response/NoticeResponseDTO.java | 1 - .../notice/entity/enums/NoticeCategory.java | 20 +++- .../service/query/NoticeQueryService.java | 11 +- .../service/query/NoticeQueryServiceImpl.java | 22 ++-- .../withtimebe/global/config/WebConfig.java | 2 + .../global/data/OAuth2ConfigData.java | 38 ++++++ .../global/error/code/NoticeErrorCode.java | 2 + .../global/error/code/OAuthErrorCode.java | 26 +++++ .../global/error/code/SocialErrorCode.java | 25 ++++ .../error/exception/OAuthException.java | 10 ++ .../error/exception/SocialException.java | 11 ++ .../global/security/SecurityConfig.java | 4 + .../CustomAuthenticationSuccessHandler.java | 21 +--- .../security/manager/CookieTokenManager.java | 38 ++++++ .../global/security/manager/TokenManager.java | 9 ++ src/main/resources/application-develop.yml | 39 +++++++ 43 files changed, 899 insertions(+), 131 deletions(-) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/controller/OAuth2Controller.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/converter/OAuth2Converter.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/dto/response/OAuth2ResponseDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/factory/OAuth2UserLoader.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/factory/OAuth2UserLoaderFactory.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/AbstractOAuth2UserLoader.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/GoogleUserLoader.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/KakaoUserLoader.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/NaverUserLoader.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/GoogleOAuth2ResponseDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/KakaoOAuth2ResponseDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/NaverOAuth2ResponseDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/OAuth2CommandService.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/OAuth2CommandServiceImpl.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/member/converter/SocialConverter.java delete mode 100644 src/main/java/org/withtime/be/withtimebe/domain/member/entity/enums/ProviderType.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/member/repository/SocialRepository.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeCategoryConverter.java create mode 100644 src/main/java/org/withtime/be/withtimebe/global/data/OAuth2ConfigData.java create mode 100644 src/main/java/org/withtime/be/withtimebe/global/error/code/OAuthErrorCode.java create mode 100644 src/main/java/org/withtime/be/withtimebe/global/error/code/SocialErrorCode.java create mode 100644 src/main/java/org/withtime/be/withtimebe/global/error/exception/OAuthException.java create mode 100644 src/main/java/org/withtime/be/withtimebe/global/error/exception/SocialException.java create mode 100644 src/main/java/org/withtime/be/withtimebe/global/security/manager/CookieTokenManager.java create mode 100644 src/main/java/org/withtime/be/withtimebe/global/security/manager/TokenManager.java diff --git a/build.gradle b/build.gradle index e678725..6555ead 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + // OAuth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // test testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/AuthController.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/AuthController.java index 2ad149b..f34509e 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/AuthController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/AuthController.java @@ -24,7 +24,7 @@ public class AuthController { private final AuthCommandService authCommandService; private final EmailCommandService emailCommandService; - @Operation(summary = "회원가입 API by 요시", description = "최초 회원가입 시 필요한 정보를 포함하여 회원가입 진행") + @Operation(summary = "회원가입 API by 요시", description = "최초 회원가입 시 필요한 정보를 포함하여 회원가입 진행, 소셜 로그인인 경우에만 socialId 포함하고 아닌 경우 제거하거나 null") @ApiResponses({ @ApiResponse(responseCode = "204", description = "회원가입 성공"), @ApiResponse( @@ -33,6 +33,13 @@ public class AuthController { 다음과 같은 이유로 실패할 수 있습니다: - AUTH400_1: 이미 존재하는 이메일입니다. """ + ), + @ApiResponse( + responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - SOCIAL404_1: 소셜을 찾을 수 없습니다. + """ ) }) @PostMapping("/sign-up") diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/OAuth2Controller.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/OAuth2Controller.java new file mode 100644 index 0000000..ff11b3d --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/controller/OAuth2Controller.java @@ -0,0 +1,55 @@ +package org.withtime.be.withtimebe.domain.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.web.bind.annotation.*; +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.service.command.OAuth2CommandService; + +@RestController +@RequestMapping("/api/v1/oauth2") +@RequiredArgsConstructor +@Tag(name = "소셜 로그인 API") +public class OAuth2Controller { + + private final OAuth2CommandService oAuth2CommandService; + + @Operation(summary = "소셜 로그인 API by 요시", description = "/oauth2/authorization/{provider}로 서버에 요청을 보낸 뒤 리다이렉트된 URI의 코드를 사용하여 요청, 리다이렉트되는 URI 의 Endpoint는 해당 API와 동일합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", + description = """ + 소셜 로그인 성공 + - isFirst: true 시 최초 회원가입 필요, 이메일은 인증된 상태로 1시간 유효 + - isFirst: false 시 최초 회원가입 필요 X, 로그인 처리 + """ + ), + @ApiResponse( + responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - AUTH400_1: 지원하지 않는 소셜 로그인입니다. provider가 잘못되었거나 지원하지 않는 provider입니다. + """ + ), + @ApiResponse( + responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - AUTH500_1: 사용자 정보를 가져오는데 실패했습니다. 인가코드가 잘못되었거나 OAuth2 인증 서버나 리소스 서버에 보낸 요청이 실패했습니다. + """ + ), + + }) + @GetMapping("/callback/{provider}") + public DefaultResponse loginWithOAuth2(HttpServletRequest request, HttpServletResponse response, + @Parameter(description = "소셜 로그인 플랫폼(대소문자 상관 없음), [kakao, google, naver]", example = "kakao") @PathVariable String provider, + @RequestParam String code) { + return DefaultResponse.ok(oAuth2CommandService.login(request, response, provider, code)); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/converter/OAuth2Converter.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/converter/OAuth2Converter.java new file mode 100644 index 0000000..06e3ea7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/converter/OAuth2Converter.java @@ -0,0 +1,41 @@ +package org.withtime.be.withtimebe.domain.auth.converter; + +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.factory.support.dto.GoogleOAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.factory.support.dto.KakaoOAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.factory.support.dto.NaverOAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType; + +public class OAuth2Converter { + public static OAuth2ResponseDTO.Login toLogin(String email, boolean isFirst, Long socialId) { + return OAuth2ResponseDTO.Login.builder() + .email(email) + .socialId(socialId) + .isFirst(isFirst) + .build(); + } + + public static OAuth2ResponseDTO.GetUserInfo toGetUserInfo(KakaoOAuth2ResponseDTO.KakaoProfile kakaoProfile) { + return OAuth2ResponseDTO.GetUserInfo.builder() + .email(kakaoProfile.kakao_account().email()) + .providerId(String.valueOf(kakaoProfile.id())) + .socialType(SocialType.KAKAO) + .build(); + } + + public static OAuth2ResponseDTO.GetUserInfo toGetUserInfo(NaverOAuth2ResponseDTO.UserInfo.UserInfoData naver) { + return OAuth2ResponseDTO.GetUserInfo.builder() + .email(naver.email()) + .providerId(naver.id()) + .socialType(SocialType.NAVER) + .build(); + } + + public static OAuth2ResponseDTO.GetUserInfo toGetUserInfo(GoogleOAuth2ResponseDTO.UserInfo google) { + return OAuth2ResponseDTO.GetUserInfo.builder() + .email(google.email()) + .providerId(google.id()) + .socialType(SocialType.GOOGLE) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/request/AuthRequestDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/request/AuthRequestDTO.java index dca0fbf..858b1c3 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/request/AuthRequestDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/request/AuthRequestDTO.java @@ -22,7 +22,8 @@ public record SignUp( String password, Gender gender, String phoneNumber, - LocalDate birth + LocalDate birth, + Long socialId ) { } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/response/OAuth2ResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/response/OAuth2ResponseDTO.java new file mode 100644 index 0000000..038e8cd --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/dto/response/OAuth2ResponseDTO.java @@ -0,0 +1,26 @@ +package org.withtime.be.withtimebe.domain.auth.dto.response; + +import lombok.Builder; +import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType; + +public record OAuth2ResponseDTO() { + + @Builder + public record Login( + String email, + Long socialId, + boolean isFirst + ) { + + } + + @Builder + public record GetUserInfo( + String email, + String providerId, + SocialType socialType + ) { + + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/OAuth2UserLoader.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/OAuth2UserLoader.java new file mode 100644 index 0000000..583058c --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/OAuth2UserLoader.java @@ -0,0 +1,8 @@ +package org.withtime.be.withtimebe.domain.auth.factory; + +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; + +public interface OAuth2UserLoader { + OAuth2ResponseDTO.GetUserInfo loadUser(String code); + String getSocialType(); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/OAuth2UserLoaderFactory.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/OAuth2UserLoaderFactory.java new file mode 100644 index 0000000..9d834b8 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/OAuth2UserLoaderFactory.java @@ -0,0 +1,29 @@ +package org.withtime.be.withtimebe.domain.auth.factory; + +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class OAuth2UserLoaderFactory { + + private final Map oAuth2UserLoaderMap = new ConcurrentHashMap<>(); + + public OAuth2UserLoaderFactory(List oAuth2UserLoaders) { + oAuth2UserLoaders.forEach( + oAuth2UserLoader -> { + String socialType = oAuth2UserLoader.getSocialType().toLowerCase(); + if (oAuth2UserLoaderMap.get(socialType) != null) { + throw new IllegalStateException("OAuth2UserLoader social type 중복: " + socialType); + } + oAuth2UserLoaderMap.put(socialType, oAuth2UserLoader); + } + ); + } + + public OAuth2UserLoader getUserLoader(String provider) { + return oAuth2UserLoaderMap.get(provider.toLowerCase()); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/AbstractOAuth2UserLoader.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/AbstractOAuth2UserLoader.java new file mode 100644 index 0000000..430442d --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/AbstractOAuth2UserLoader.java @@ -0,0 +1,108 @@ +package org.withtime.be.withtimebe.domain.auth.factory.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.factory.OAuth2UserLoader; +import org.withtime.be.withtimebe.global.data.OAuth2ConfigData; +import org.withtime.be.withtimebe.global.error.code.OAuthErrorCode; +import org.withtime.be.withtimebe.global.error.exception.OAuthException; + +import java.io.IOException; +import java.util.Optional; + +@RequiredArgsConstructor +public abstract class AbstractOAuth2UserLoader implements OAuth2UserLoader { + + private final OAuth2ConfigData oAuth2ConfigData; + + @Override + public OAuth2ResponseDTO.GetUserInfo loadUser(String code) { + try { + String token = getAccessToken(code); + return getUserInfo(token); + } + catch (Exception e) { + throw new OAuthException(OAuthErrorCode.FAIL_TO_GET_USER_INFO); + } + } + + protected abstract String getAccessToken(String code) throws IOException; + + protected abstract OAuth2ResponseDTO.GetUserInfo getUserInfo(String token) throws IOException; + + protected T getToken(String code, Class clz) throws IOException { + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders httpHeaders = new HttpHeaders(); + + httpHeaders.add("Content-Type", "application/x-www-form-urlencoded"); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", getClientId()); + params.add("redirect_uri", getRedirectUri()); + params.add("code", code); + Optional.ofNullable(getClientSecret()).ifPresent(secret -> params.add("client_secret", secret)); + HttpEntity request = new HttpEntity<>(params, httpHeaders); + + ResponseEntity response = restTemplate.exchange( + getTokenUri(), + HttpMethod.POST, + request, + String.class); + + ObjectMapper objectMapper = new ObjectMapper(); + + return objectMapper.readValue(response.getBody(), clz); + + } + + protected T getProfile(String tokenPrefix, String token, Class clz) throws IOException { + // 토큰으로 정보 가져오기 + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders httpHeaders = new HttpHeaders(); + + httpHeaders.add("Authorization", tokenPrefix + token); + httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); + + HttpEntity request1 = new HttpEntity<>(httpHeaders); + + ResponseEntity response = restTemplate.exchange( + getUserInfoUri(), + HttpMethod.GET, + request1, + String.class + ); + + ObjectMapper om = new ObjectMapper(); + + return om.readValue(response.getBody(), clz); + } + + protected String getClientId() { + return this.oAuth2ConfigData.getRegistration().get(this.getSocialType()).getClientId(); + } + + protected String getClientSecret() { + return this.oAuth2ConfigData.getRegistration().get(this.getSocialType()).getClientSecret(); + } + + protected String getRedirectUri() { + return this.oAuth2ConfigData.getRegistration().get(this.getSocialType()).getRedirectUri(); + } + + protected String getTokenUri() { + return this.oAuth2ConfigData.getProvider().get(this.getSocialType()).getTokenUri(); + } + + protected String getUserInfoUri() { + return this.oAuth2ConfigData.getProvider().get(this.getSocialType()).getUserInfoUri(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/GoogleUserLoader.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/GoogleUserLoader.java new file mode 100644 index 0000000..c6d71cd --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/GoogleUserLoader.java @@ -0,0 +1,39 @@ +package org.withtime.be.withtimebe.domain.auth.factory.support; + +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.auth.converter.OAuth2Converter; +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.factory.support.dto.GoogleOAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType; +import org.withtime.be.withtimebe.global.data.OAuth2ConfigData; + +import java.io.IOException; + +@Component +public class GoogleUserLoader extends AbstractOAuth2UserLoader { + + private static final String AUTHORIZATION_TOKEN_PREFIX = "Bearer "; + private static final SocialType SOCIAL_TYPE = SocialType.GOOGLE; + + public GoogleUserLoader(OAuth2ConfigData oAuth2ConfigData) { + super(oAuth2ConfigData); + } + + @Override + protected String getAccessToken(String code) throws IOException { + GoogleOAuth2ResponseDTO.Token token = super.getToken(code, GoogleOAuth2ResponseDTO.Token.class); + return token.access_token(); + } + + @Override + protected OAuth2ResponseDTO.GetUserInfo getUserInfo(String token) throws IOException { + GoogleOAuth2ResponseDTO.UserInfo userInfo = super.getProfile(AUTHORIZATION_TOKEN_PREFIX, token, GoogleOAuth2ResponseDTO.UserInfo.class); + return OAuth2Converter.toGetUserInfo(userInfo); + } + + @Override + public String getSocialType() { + return SOCIAL_TYPE.name().toLowerCase(); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/KakaoUserLoader.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/KakaoUserLoader.java new file mode 100644 index 0000000..6d7d6af --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/KakaoUserLoader.java @@ -0,0 +1,40 @@ +package org.withtime.be.withtimebe.domain.auth.factory.support; + +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.auth.converter.OAuth2Converter; +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.factory.support.dto.KakaoOAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType; +import org.withtime.be.withtimebe.global.data.OAuth2ConfigData; + +import java.io.IOException; + +@Component +public class KakaoUserLoader extends AbstractOAuth2UserLoader { + + private static final String AUTHORIZATION_TOKEN_PREFIX = "Bearer "; + private static final SocialType SOCIAL_TYPE = SocialType.KAKAO; + + public KakaoUserLoader(OAuth2ConfigData oAuth2ConfigData) { + super(oAuth2ConfigData); + } + + @Override + protected String getAccessToken(String code) throws IOException { + KakaoOAuth2ResponseDTO.Token oAuth2TokenDTO = getToken(code, KakaoOAuth2ResponseDTO.Token.class); + return oAuth2TokenDTO.access_token(); + } + + @Override + protected OAuth2ResponseDTO.GetUserInfo getUserInfo(String token) throws IOException { + KakaoOAuth2ResponseDTO.KakaoProfile kakaoProfile = super.getProfile(AUTHORIZATION_TOKEN_PREFIX, token, KakaoOAuth2ResponseDTO.KakaoProfile.class); + return OAuth2Converter.toGetUserInfo(kakaoProfile); + } + + + @Override + public String getSocialType() { + return SOCIAL_TYPE.name().toLowerCase(); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/NaverUserLoader.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/NaverUserLoader.java new file mode 100644 index 0000000..893a33a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/NaverUserLoader.java @@ -0,0 +1,38 @@ +package org.withtime.be.withtimebe.domain.auth.factory.support; + +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.auth.converter.OAuth2Converter; +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.factory.support.dto.NaverOAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType; +import org.withtime.be.withtimebe.global.data.OAuth2ConfigData; + +import java.io.IOException; + +@Component +public class NaverUserLoader extends AbstractOAuth2UserLoader { + + private static final String AUTHORIZATION_TOKEN_PREFIX = "Bearer "; + private static final SocialType SOCIAL_TYPE = SocialType.NAVER; + + public NaverUserLoader(OAuth2ConfigData oAuth2ConfigData) { + super(oAuth2ConfigData); + } + + @Override + protected String getAccessToken(String code) throws IOException { + NaverOAuth2ResponseDTO.Token oAuth2TokenDTO = getToken(code, NaverOAuth2ResponseDTO.Token.class); + return oAuth2TokenDTO.access_token(); + } + + @Override + protected OAuth2ResponseDTO.GetUserInfo getUserInfo(String token) throws IOException { + NaverOAuth2ResponseDTO.UserInfo.UserInfoData userInfo = super.getProfile(AUTHORIZATION_TOKEN_PREFIX, token, NaverOAuth2ResponseDTO.UserInfo.class).response(); + return OAuth2Converter.toGetUserInfo(userInfo); + } + + @Override + public String getSocialType() { + return SOCIAL_TYPE.name().toLowerCase(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/GoogleOAuth2ResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/GoogleOAuth2ResponseDTO.java new file mode 100644 index 0000000..383adda --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/GoogleOAuth2ResponseDTO.java @@ -0,0 +1,23 @@ +package org.withtime.be.withtimebe.domain.auth.factory.support.dto; + +public record GoogleOAuth2ResponseDTO() { + public record Token( + String access_token, + String refresh_token, + Long expires_in, + String token_type, + String scope, + String id_token + ) { + + } + + public record UserInfo( + String id, + String email, + Boolean verified_email, + String picture + ) { + + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/KakaoOAuth2ResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/KakaoOAuth2ResponseDTO.java new file mode 100644 index 0000000..e7b80f4 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/KakaoOAuth2ResponseDTO.java @@ -0,0 +1,53 @@ +package org.withtime.be.withtimebe.domain.auth.factory.support.dto; + +import lombok.Getter; + +public record KakaoOAuth2ResponseDTO () { + + public record Token ( + String token_type, + String access_token, + String refresh_token, + Long expires_in, + Long refresh_token_expires_in, + String scope + ) { + } + + public record KakaoProfile ( + Long id, + String connected_at, + Properties properties, + KakaoAccount kakao_account + ) { + + public record Properties ( + String nickname, + String profile_image, + String thumbnail_image + ) { + } + + public record KakaoAccount ( + String email, + Boolean is_email_verified, + Boolean email_needs_agreement, + Boolean has_email, + Boolean profile_nickname_needs_agreement, + Boolean profile_image_needs_agreement, + Boolean email_needs_argument, + Boolean is_email_valid, + Profile profile + ) { + + public record Profile ( + String nickname, + String thumbnail_image_url, + String profile_image_url, + Boolean is_default_nickname, + Boolean is_default_image + ) { + } + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/NaverOAuth2ResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/NaverOAuth2ResponseDTO.java new file mode 100644 index 0000000..9545438 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/factory/support/dto/NaverOAuth2ResponseDTO.java @@ -0,0 +1,24 @@ +package org.withtime.be.withtimebe.domain.auth.factory.support.dto; + +public record NaverOAuth2ResponseDTO() { + public record Token( + String access_token, + String refresh_token, + String token_type, + String expires_in + ) { + } + + public record UserInfo( + String resultcode, + String message, + UserInfoData response + ) { + public record UserInfoData( + String id, + String email + ) { + + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/AuthCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/AuthCommandServiceImpl.java index 48d7d10..cdd75fd 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/AuthCommandServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/AuthCommandServiceImpl.java @@ -12,15 +12,11 @@ import org.withtime.be.withtimebe.domain.auth.service.query.TokenQueryService; import org.withtime.be.withtimebe.domain.auth.service.query.TokenStorageQueryService; import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.entity.Social; import org.withtime.be.withtimebe.domain.member.repository.MemberRepository; -import org.withtime.be.withtimebe.global.error.code.AuthErrorCode; -import org.withtime.be.withtimebe.global.error.code.EmailErrorCode; -import org.withtime.be.withtimebe.global.error.code.MemberErrorCode; -import org.withtime.be.withtimebe.global.error.code.TokenErrorCode; -import org.withtime.be.withtimebe.global.error.exception.AuthException; -import org.withtime.be.withtimebe.global.error.exception.EmailException; -import org.withtime.be.withtimebe.global.error.exception.MemberException; -import org.withtime.be.withtimebe.global.error.exception.TokenException; +import org.withtime.be.withtimebe.domain.member.repository.SocialRepository; +import org.withtime.be.withtimebe.global.error.code.*; +import org.withtime.be.withtimebe.global.error.exception.*; import org.withtime.be.withtimebe.global.security.constants.AuthenticationConstants; import org.withtime.be.withtimebe.global.security.domain.CustomUserDetails; import org.withtime.be.withtimebe.global.util.CookieUtil; @@ -32,6 +28,7 @@ public class AuthCommandServiceImpl implements AuthCommandService { private final PasswordEncoder passwordEncoder; private final MemberRepository memberRepository; + private final SocialRepository socialRepository; private final TokenCommandService tokenCommandService; private final TokenStorageCommandService tokenStorageCommandService; private final TokenQueryService tokenQueryService; @@ -42,8 +39,12 @@ public class AuthCommandServiceImpl implements AuthCommandService { public void signUp(AuthRequestDTO.SignUp request) { validateSignUp(request); - Member member = AuthConverter.toLocalMember(request.email(), request.username(), passwordEncoder.encode(request.password()), request.phoneNumber(), request.gender(), request.birth()); - memberRepository.save(member); + Member member = memberRepository.save(AuthConverter.toLocalMember(request.email(), request.username(), passwordEncoder.encode(request.password()), request.phoneNumber(), request.gender(), request.birth())); + if (request.socialId() != null) { + Social social = socialRepository.findById(request.socialId()).orElseThrow(() -> + new SocialException(SocialErrorCode.NOT_FOUND_SOCIAL)); + social.addMember(member); + } } @Override diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/OAuth2CommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/OAuth2CommandService.java new file mode 100644 index 0000000..cd81cd7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/OAuth2CommandService.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.auth.service.command; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; + +public interface OAuth2CommandService { + OAuth2ResponseDTO.Login login(HttpServletRequest request, HttpServletResponse response, String provider, String code); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/OAuth2CommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/OAuth2CommandServiceImpl.java new file mode 100644 index 0000000..4c547ff --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/auth/service/command/OAuth2CommandServiceImpl.java @@ -0,0 +1,69 @@ +package org.withtime.be.withtimebe.domain.auth.service.command; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.auth.converter.OAuth2Converter; +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.factory.OAuth2UserLoader; +import org.withtime.be.withtimebe.domain.auth.factory.OAuth2UserLoaderFactory; +import org.withtime.be.withtimebe.domain.member.converter.SocialConverter; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.entity.Social; +import org.withtime.be.withtimebe.domain.member.repository.MemberRepository; +import org.withtime.be.withtimebe.domain.member.repository.SocialRepository; +import org.withtime.be.withtimebe.global.error.code.OAuthErrorCode; +import org.withtime.be.withtimebe.global.error.exception.OAuthException; +import org.withtime.be.withtimebe.global.security.domain.CustomUserDetails; +import org.withtime.be.withtimebe.global.security.manager.TokenManager; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional +public class OAuth2CommandServiceImpl implements OAuth2CommandService { + + private final OAuth2UserLoaderFactory oAuth2UserLoaderFactory; + private final SocialRepository socialRepository; + private final MemberRepository memberRepository; + private final TokenManager tokenManager; + + private final EmailVerificationCodeStorageCommandService emailVerificationCodeStorageCommandService; + + @Override + public OAuth2ResponseDTO.Login login(HttpServletRequest request, HttpServletResponse response, String provider, String code) { + OAuth2UserLoader userLoader = oAuth2UserLoaderFactory.getUserLoader(provider); + if (userLoader == null) { + throw new OAuthException(OAuthErrorCode.UNSUPPORTED_SOCIAL_TYPE); + } + OAuth2ResponseDTO.GetUserInfo userInfo = userLoader.loadUser(code); + return successfulOAuth2(request, response, userInfo); + } + + private OAuth2ResponseDTO.Login successfulOAuth2(HttpServletRequest request, HttpServletResponse response, OAuth2ResponseDTO.GetUserInfo userInfo) { + Optional socialOptional = socialRepository.findByProviderIdAndSocialType(userInfo.providerId(), userInfo.socialType()); + Optional memberOptional = memberRepository.findByEmail(userInfo.email()); + + // 해당 이메일로 회원가입이 된 경우 + if (memberOptional.isPresent()) { + // 소셜이 있으면 가져오고 아니면 새로 만들기 + Social social = socialOptional.orElseGet(() -> socialRepository.save(SocialConverter.toSocial(userInfo, memberOptional.get()))); + processLogin(request, response, memberOptional.get()); + return OAuth2Converter.toLogin(userInfo.email(), false, social.getId()); + } + // 회원가입이 안 된 경우 + else { + Social social = socialOptional.orElseGet(() -> socialRepository.save(SocialConverter.toSocial(userInfo))); + emailVerificationCodeStorageCommandService.saveVerifiedEmail(userInfo.email()); + return OAuth2Converter.toLogin(userInfo.email(), true, social.getId()); + } + } + + private void processLogin(HttpServletRequest request, HttpServletResponse response, Member member) { + CustomUserDetails customUserDetails = new CustomUserDetails(member); + tokenManager.addToken(request, response, customUserDetails); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/converter/SocialConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/member/converter/SocialConverter.java new file mode 100644 index 0000000..89918a7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/converter/SocialConverter.java @@ -0,0 +1,22 @@ +package org.withtime.be.withtimebe.domain.member.converter; + +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.entity.Social; + +public class SocialConverter { + public static Social toSocial(OAuth2ResponseDTO.GetUserInfo userInfo) { + return Social.builder() + .socialType(userInfo.socialType()) + .providerId(userInfo.providerId()) + .build(); + } + + public static Social toSocial(OAuth2ResponseDTO.GetUserInfo userInfo, Member member) { + return Social.builder() + .socialType(userInfo.socialType()) + .providerId(userInfo.providerId()) + .member(member) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java index fb0aba4..c97e6a7 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Member.java @@ -3,7 +3,6 @@ import jakarta.persistence.*; import lombok.*; import org.withtime.be.withtimebe.domain.member.entity.enums.Gender; -import org.withtime.be.withtimebe.domain.member.entity.enums.ProviderType; import org.withtime.be.withtimebe.domain.member.entity.enums.Role; import org.withtime.be.withtimebe.domain.member.entity.enums.UserRank; import org.withtime.be.withtimebe.global.common.BaseEntity; diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Social.java b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Social.java index 5cadc14..b6e8fd3 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Social.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/Social.java @@ -27,4 +27,8 @@ public class Social extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; + + public void addMember(Member member) { + this.member = member; + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/enums/ProviderType.java b/src/main/java/org/withtime/be/withtimebe/domain/member/entity/enums/ProviderType.java deleted file mode 100644 index d2f7a66..0000000 --- a/src/main/java/org/withtime/be/withtimebe/domain/member/entity/enums/ProviderType.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.withtime.be.withtimebe.domain.member.entity.enums; - -public enum ProviderType { - KAKAO, GOOGLE, NAVER, LOCAL -} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/member/repository/SocialRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/member/repository/SocialRepository.java new file mode 100644 index 0000000..e8dd17e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/member/repository/SocialRepository.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.domain.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.member.entity.Social; +import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType; + +import java.util.Optional; + +public interface SocialRepository extends JpaRepository { + Optional findByProviderIdAndSocialType(String providerId, SocialType socialType); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java index ff7c0df..3cae7be 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java @@ -16,6 +16,7 @@ import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; import org.withtime.be.withtimebe.domain.notice.dto.response.NoticeResponseDTO; import org.withtime.be.withtimebe.domain.notice.entity.Notice; +import org.withtime.be.withtimebe.domain.notice.entity.enums.NoticeCategory; import org.withtime.be.withtimebe.domain.notice.service.query.NoticeQueryService; import org.withtime.be.withtimebe.global.annotation.SwaggerPageable; import org.withtime.be.withtimebe.global.security.annotation.AuthenticatedMember; @@ -47,10 +48,9 @@ public class NoticeQueryController { @GetMapping public DefaultResponse findNoticeList( @PageableDefault(page = 0, size = 10) Pageable pageable, - @RequestParam String noticeCategory + @RequestParam NoticeCategory noticeCategory ) { - NoticeRequestDTO.FindNoticeList request = NoticeConverter.toFindNoticeList(pageable, noticeCategory); - Page result = noticeQueryService.findNoticeList(request); + Page result = noticeQueryService.findNoticeList(pageable, noticeCategory); NoticeResponseDTO.NoticeList response = NoticeConverter.toNoticeList(result); return DefaultResponse.ok(response); } @@ -70,10 +70,9 @@ public DefaultResponse findNoticeList( public DefaultResponse findNoticeListByKeyword( @PageableDefault(page = 0, size = 10) Pageable pageable, @RequestParam String keyword, - @RequestParam String noticeCategory + @RequestParam NoticeCategory noticeCategory ) { - NoticeRequestDTO.FindNoticeListByKeyword request = NoticeConverter.toFindNoticeListByKeyword(pageable, keyword, noticeCategory); - Page result = noticeQueryService.findNoticeListByKeyword(request); + Page result = noticeQueryService.findNoticeListByKeyword(pageable, keyword, noticeCategory); NoticeResponseDTO.NoticeList response = NoticeConverter.toNoticeList(result); return DefaultResponse.ok(response); } @@ -97,9 +96,8 @@ public DefaultResponse findNoticeDetail( @PathVariable("noticeId") Long noticeId, @AuthenticatedMember Member member ) { - NoticeRequestDTO.FindNoticeDetail request = NoticeConverter.toFindNoticeDetail(noticeId, member); - Notice result = noticeQueryService.findNoticeDetail(request); - NoticeResponseDTO.NoticeDetail response = NoticeConverter.toNoticeDetail(result, member); + Notice result = noticeQueryService.findNoticeDetail(noticeId, member); + NoticeResponseDTO.NoticeDetail response = NoticeConverter.toNoticeDetail(result); return DefaultResponse.ok(response); } @@ -121,10 +119,9 @@ public DefaultResponse findNoticeDetail( @GetMapping("/trash") public DefaultResponse findTrashNoticeList( @PageableDefault(page = 0, size = 10) Pageable pageable, - @RequestParam String noticeCategory + @RequestParam NoticeCategory noticeCategory ) { - NoticeRequestDTO.FindNoticeList request = NoticeConverter.toFindNoticeList(pageable, noticeCategory); - Page result = noticeQueryService.findTrashNoticeList(request); + Page result = noticeQueryService.findTrashNoticeList(pageable, noticeCategory); NoticeResponseDTO.NoticeList response = NoticeConverter.toNoticeList(result); return DefaultResponse.ok(response); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeCategoryConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeCategoryConverter.java new file mode 100644 index 0000000..5d083ca --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeCategoryConverter.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.notice.converter; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.util.StringUtils; +import org.withtime.be.withtimebe.domain.notice.entity.enums.NoticeCategory; +import org.withtime.be.withtimebe.global.error.code.NoticeErrorCode; +import org.withtime.be.withtimebe.global.error.exception.NoticeException; + +public class NoticeCategoryConverter implements Converter { + + @Override + public NoticeCategory convert(String source) { + if(!StringUtils.hasText(source)) throw new NoticeException(NoticeErrorCode.NOTICE_CATEGORY_EMPTY); + return NoticeCategory.findNoticeCategory(source); + } +} + diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeConverter.java index 5d7276c..54a5d23 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeConverter.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeConverter.java @@ -15,50 +15,6 @@ public class NoticeConverter { - // Request DTO : 전체 조회 (Controller -> Service) - public static NoticeRequestDTO.FindNoticeList toFindNoticeList(Pageable pageable, String type) { - - NoticeCategory noticeCategory; - - try { - noticeCategory = NoticeCategory.valueOf(type); - } catch (IllegalArgumentException e) { - throw new NoticeException(NoticeErrorCode.NOTICE_CATEGORY_NOT_FOUND); - } - - return NoticeRequestDTO.FindNoticeList.builder() - .pageable(pageable) - .noticeCategory(noticeCategory) - .build(); - } - - // Request DTO : 검색어 전체 조회 (Controller -> Service) - public static NoticeRequestDTO.FindNoticeListByKeyword toFindNoticeListByKeyword(Pageable pageable, String keyword, String type) { - - NoticeCategory noticeCategory; - - try { - noticeCategory = NoticeCategory.valueOf(type); - } catch (IllegalArgumentException e) { - throw new NoticeException(NoticeErrorCode.NOTICE_CATEGORY_NOT_FOUND); - } - - return NoticeRequestDTO.FindNoticeListByKeyword.builder() - .pageable(pageable) - .keyword(keyword) - .noticeCategory(noticeCategory) - .build(); - } - - // Request : 상세 조회 요청 (Controller -> Service) DTO - public static NoticeRequestDTO.FindNoticeDetail toFindNoticeDetail(Long noticeId, Member member) { - - return NoticeRequestDTO.FindNoticeDetail.builder() - .noticeId(noticeId) - .member(member) - .build(); - } - // Response DTO : NoticeResponseDTO.NoticeList public static NoticeResponseDTO.NoticeList toNoticeList(Page noticePage) { @@ -87,16 +43,13 @@ public static NoticeResponseDTO.Notice toNotice(Notice notice) { } // Response : NoticeDetail(DTO)로 변환 - public static NoticeResponseDTO.NoticeDetail toNoticeDetail(Notice notice, Member member) { - - boolean hasAdminAuth = member != null && member.getRole().equals(Role.ADMIN); + public static NoticeResponseDTO.NoticeDetail toNoticeDetail(Notice notice) { return NoticeResponseDTO.NoticeDetail.builder() .noticeId(notice.getId()) .title(notice.getTitle()) .content(notice.getContent()) .isPinned(notice.getIsPinned()) - .hasAdminAuth(hasAdminAuth) .createdAt(notice.getCreatedAt()) .build(); } @@ -108,6 +61,7 @@ public static Notice toNoticeEntity(NoticeRequestDTO.CreateNotice request, Membe .title(request.title()) .content(request.content()) .isPinned(request.isPinned()) + .noticeCategory(request.noticeCategory()) .build(); } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/request/NoticeRequestDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/request/NoticeRequestDTO.java index b6e28b5..f3aceaa 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/request/NoticeRequestDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/request/NoticeRequestDTO.java @@ -10,32 +10,15 @@ public record NoticeRequestDTO() { - @Builder - public record FindNoticeList( - Pageable pageable, // 게시글 식별자 값 - NoticeCategory noticeCategory // 게시글 유형 - ) {} - - @Builder - public record FindNoticeListByKeyword( - Pageable pageable, // 게시글 식별자 값 - String keyword, // 검색 키워드 - NoticeCategory noticeCategory // 게시글 유형 - ) {} - - @Builder - public record FindNoticeDetail( - Long noticeId, - Member member - ) {} - public record CreateNotice( @NotBlank(message = "제목을 입력해주세요") String title, @NotBlank(message = "내용을 입력해주세요") String content, @NotNull(message = "상단 고정 여부를 결정해주세요") - Boolean isPinned + Boolean isPinned, + @NotNull(message = "공지사항 유형을 입력해주세요") + NoticeCategory noticeCategory ) {} public record UpdateNotice ( diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/response/NoticeResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/response/NoticeResponseDTO.java index e4fc557..1c8de43 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/response/NoticeResponseDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/response/NoticeResponseDTO.java @@ -30,7 +30,6 @@ public record NoticeDetail( String title, // 게시글 제목 String content, // 게시글 내용 Boolean isPinned, // 고정 여부 - Boolean hasAdminAuth, // 어드민 여부 LocalDateTime createdAt // 생성 날짜 ) {} } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/enums/NoticeCategory.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/enums/NoticeCategory.java index a05920f..c12f003 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/enums/NoticeCategory.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/enums/NoticeCategory.java @@ -1,6 +1,24 @@ package org.withtime.be.withtimebe.domain.notice.entity.enums; +import java.util.Arrays; + +import org.withtime.be.withtimebe.global.error.code.NoticeErrorCode; +import org.withtime.be.withtimebe.global.error.exception.NoticeException; + +import com.fasterxml.jackson.annotation.JsonCreator; + public enum NoticeCategory { SERVICE, - SYSTEM + SYSTEM; + + // @RequestBody + @JsonCreator + public static NoticeCategory findNoticeCategory(String name) { + return Arrays.stream(values()) + .filter(type -> type.name().equalsIgnoreCase(name)) + .findAny() + .orElseThrow( + () -> new NoticeException(NoticeErrorCode.NOTICE_CATEGORY_NOT_FOUND) + ); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryService.java index bc6bd86..3b0a933 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryService.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryService.java @@ -1,12 +1,15 @@ package org.withtime.be.withtimebe.domain.notice.service.query; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.withtime.be.withtimebe.domain.member.entity.Member; import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; import org.withtime.be.withtimebe.domain.notice.entity.Notice; +import org.withtime.be.withtimebe.domain.notice.entity.enums.NoticeCategory; public interface NoticeQueryService { - Page findNoticeList(NoticeRequestDTO.FindNoticeList request); - Page findNoticeListByKeyword(NoticeRequestDTO.FindNoticeListByKeyword request); - Page findTrashNoticeList(NoticeRequestDTO.FindNoticeList request); - Notice findNoticeDetail(NoticeRequestDTO.FindNoticeDetail request); + Page findNoticeList(Pageable pageable, NoticeCategory noticeCategory); + Page findNoticeListByKeyword(Pageable pageable, String keyword, NoticeCategory noticeCategory); + Page findTrashNoticeList(Pageable pageable, NoticeCategory noticeCategory); + Notice findNoticeDetail(Long noticeId, Member member); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryServiceImpl.java index 4846417..0baac4a 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryServiceImpl.java @@ -1,12 +1,15 @@ package org.withtime.be.withtimebe.domain.notice.service.query; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.withtime.be.withtimebe.domain.member.entity.Member; import org.withtime.be.withtimebe.domain.member.entity.enums.Role; +import org.withtime.be.withtimebe.domain.member.repository.MemberRepository; import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; import org.withtime.be.withtimebe.domain.notice.entity.Notice; +import org.withtime.be.withtimebe.domain.notice.entity.enums.NoticeCategory; import org.withtime.be.withtimebe.domain.notice.repository.NoticeRepository; import org.withtime.be.withtimebe.global.error.code.AuthErrorCode; import org.withtime.be.withtimebe.global.error.code.NoticeErrorCode; @@ -23,32 +26,31 @@ public class NoticeQueryServiceImpl implements NoticeQueryService { private final NoticeRepository noticeRepository; @Override - public Page findNoticeList(NoticeRequestDTO.FindNoticeList request) { + public Page findNoticeList(Pageable pageable, NoticeCategory noticeCategory) { return noticeRepository.findNoticeListByNoticeCategory( - request.noticeCategory(), request.pageable()); + noticeCategory, pageable); } @Override - public Page findNoticeListByKeyword(NoticeRequestDTO.FindNoticeListByKeyword request) { + public Page findNoticeListByKeyword(Pageable pageable, String keyword, NoticeCategory noticeCategory) { return noticeRepository.findNoticeListByNoticeCategoryAndKeyword( - request.noticeCategory(), request.keyword(), request.pageable()); + noticeCategory, keyword, pageable); } @Override - public Page findTrashNoticeList(NoticeRequestDTO.FindNoticeList request) { + public Page findTrashNoticeList(Pageable pageable, NoticeCategory noticeCategory) { return noticeRepository.findTrashNoticeListByNoticeCategory( - request.noticeCategory(), request.pageable() + noticeCategory, pageable ); } @Override - public Notice findNoticeDetail(NoticeRequestDTO.FindNoticeDetail request) { + public Notice findNoticeDetail(Long noticeId, Member member) { - Notice notice = noticeRepository.findNoticeById(request.noticeId()) + Notice notice = noticeRepository.findNoticeById(noticeId) .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); - Member member = request.member(); - // 삭제된 게시글을 USER가 보려는 경우 처리 + // 삭제된 게시글을 'USER'가 보려는 경우 처리 if(notice.getDeletedAt() != null) { if(member == null || !member.getRole().equals(Role.ADMIN)) throw new AuthException(NoticeErrorCode.DELETED_NOTICE_FORBIDDEN_ACCESS); diff --git a/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java b/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java index 89b594a..70b377d 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java +++ b/src/main/java/org/withtime/be/withtimebe/global/config/WebConfig.java @@ -5,6 +5,7 @@ import org.springframework.format.FormatterRegistry; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.withtime.be.withtimebe.domain.notice.converter.NoticeCategoryConverter; import org.withtime.be.withtimebe.domain.faq.converter.FaqCategoryConverter; import org.withtime.be.withtimebe.global.security.annotation.resolver.AuthenticatedMemberResolver; @@ -23,6 +24,7 @@ public void addArgumentResolvers(List resolvers) @Override public void addFormatters(FormatterRegistry registry) { + registry.addConverter(new NoticeCategoryConverter()); registry.addConverter(new FaqCategoryConverter()); } } diff --git a/src/main/java/org/withtime/be/withtimebe/global/data/OAuth2ConfigData.java b/src/main/java/org/withtime/be/withtimebe/global/data/OAuth2ConfigData.java new file mode 100644 index 0000000..81efa0c --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/data/OAuth2ConfigData.java @@ -0,0 +1,38 @@ +package org.withtime.be.withtimebe.global.data; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; +import java.util.Map; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "spring.security.oauth2.client") +public class OAuth2ConfigData { + private Map registration; + private Map provider; + + @Getter + @Setter + public static class Registration { + private String clientId; + private String clientSecret; + private String redirectUri; + private String authorizationGrantType; + private String clientAuthenticationMethod; + private List scope; + } + + @Getter + @Setter + public static class Provider { + private String authorizationUri; + private String tokenUri; + private String userInfoUri; + private String userNameAttribute; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/NoticeErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/NoticeErrorCode.java index 6c6389f..32d18ba 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/error/code/NoticeErrorCode.java +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/NoticeErrorCode.java @@ -10,6 +10,8 @@ @AllArgsConstructor public enum NoticeErrorCode implements BaseErrorCode { + NOTICE_CATEGORY_EMPTY(HttpStatus.BAD_REQUEST, "NOTICE400_1", "공지사항 유형을 입력해주세요."), + DELETED_NOTICE_FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "NOTICE403_1", "삭제된 공지사항을 열람할 Admin 권한이 없습니다."), NOTICE_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTICE404_1", "해당하는 공지사항 유형을 찾을 수 없습니다."), diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/OAuthErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/OAuthErrorCode.java new file mode 100644 index 0000000..32a9aa9 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/OAuthErrorCode.java @@ -0,0 +1,26 @@ +package org.withtime.be.withtimebe.global.error.code; + +import lombok.AllArgsConstructor; +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +public enum OAuthErrorCode implements BaseErrorCode { + + FAIL_TO_GET_USER_INFO(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH500_1", "사용자 정보를 가져오는 데 실패했습니다."), + UNSUPPORTED_SOCIAL_TYPE(HttpStatus.BAD_REQUEST, "OAUTH400_1", "지원하지 않는 소셜 로그인입니다.") + ; + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public DefaultResponseErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/SocialErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/SocialErrorCode.java new file mode 100644 index 0000000..4526c34 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/SocialErrorCode.java @@ -0,0 +1,25 @@ +package org.withtime.be.withtimebe.global.error.code; + +import lombok.AllArgsConstructor; +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +public enum SocialErrorCode implements BaseErrorCode { + + NOT_FOUND_SOCIAL(HttpStatus.NOT_FOUND, "SOCIAL404_1", "해당 소셜을 찾을 수 없습니다."), + ; + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public DefaultResponseErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/OAuthException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/OAuthException.java new file mode 100644 index 0000000..ccf6743 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/OAuthException.java @@ -0,0 +1,10 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; + +public class OAuthException extends ServerApplicationException { + public OAuthException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/SocialException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/SocialException.java new file mode 100644 index 0000000..a733299 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/SocialException.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; + +public class SocialException extends ServerApplicationException { + + public SocialException(BaseErrorCode code) { + super (code); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java index b256970..fec718b 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java +++ b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -50,7 +51,9 @@ public class SecurityConfig { private String[] allowUrl = { API_PREFIX + "/auth/**", API_PREFIX + "/notices/**", + API_PREFIX + "/oauth2/**", API_PREFIX + "/faqs/**", + "/oauth2/authorization/**", "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**" @@ -90,6 +93,7 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) + .oauth2Login(Customizer.withDefaults()) .exceptionHandling(exception -> exception .accessDeniedHandler(accessDeniedHandler()) .authenticationEntryPoint(authenticationEntryPoint()) diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/handler/CustomAuthenticationSuccessHandler.java b/src/main/java/org/withtime/be/withtimebe/global/security/handler/CustomAuthenticationSuccessHandler.java index 5de3ab8..b0ca269 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/security/handler/CustomAuthenticationSuccessHandler.java +++ b/src/main/java/org/withtime/be/withtimebe/global/security/handler/CustomAuthenticationSuccessHandler.java @@ -17,6 +17,7 @@ import org.withtime.be.withtimebe.domain.auth.service.command.TokenStorageCommandService; import org.withtime.be.withtimebe.global.security.constants.AuthenticationConstants; import org.withtime.be.withtimebe.global.security.domain.CustomUserDetails; +import org.withtime.be.withtimebe.global.security.manager.TokenManager; import org.withtime.be.withtimebe.global.util.CookieUtil; import java.io.IOException; @@ -25,15 +26,12 @@ @RequiredArgsConstructor public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { - private final TokenStorageCommandService tokenStorageCommandService; - private final TokenCommandService tokenCommandService; - private final TokenQueryService tokenQueryService; + private final TokenManager tokenManager; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { CustomUserDetails customUserDetails =(CustomUserDetails) authentication.getPrincipal(); - AuthResponseDTO.Login loginResponse = tokenCommandService.createLoginToken(customUserDetails); - addToken(request, response, loginResponse, customUserDetails); + tokenManager.addToken(request, response, customUserDetails); ObjectMapper objectMapper = new ObjectMapper(); response.setStatus(HttpStatus.OK.value()); @@ -42,16 +40,5 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo objectMapper.writeValue(response.getOutputStream(), DefaultResponse.noContent()); } - private void addToken(HttpServletRequest request, HttpServletResponse response, AuthResponseDTO.Login loginResponse, CustomUserDetails customUserDetails) { - // 쿠키에 이미 토큰이 있는 경우 블랙리스트 처리 - for (String token : new String[] {CookieUtil.getCookie(request, AuthenticationConstants.ACCESS_TOKEN_NAME), CookieUtil.getCookie(request, AuthenticationConstants.REFRESH_TOKEN_NAME)}) { - if (token != null) { - tokenStorageCommandService.addBlackList(token); - } - } - - CookieUtil.addCookie(request, response, AuthenticationConstants.ACCESS_TOKEN_NAME, loginResponse.accessToken(), (int) tokenQueryService.getAccessTokenExpiration().toSeconds()); - CookieUtil.addCookie(request, response, AuthenticationConstants.REFRESH_TOKEN_NAME, loginResponse.refreshToken(), (int) tokenQueryService.getRefreshTokenExpiration().toSeconds()); - tokenStorageCommandService.addRefreshToken(customUserDetails.getId(), loginResponse.refreshToken()); - } + } diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/manager/CookieTokenManager.java b/src/main/java/org/withtime/be/withtimebe/global/security/manager/CookieTokenManager.java new file mode 100644 index 0000000..913e753 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/security/manager/CookieTokenManager.java @@ -0,0 +1,38 @@ +package org.withtime.be.withtimebe.global.security.manager; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.auth.dto.response.AuthResponseDTO; +import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO; +import org.withtime.be.withtimebe.domain.auth.service.command.TokenCommandService; +import org.withtime.be.withtimebe.domain.auth.service.command.TokenStorageCommandService; +import org.withtime.be.withtimebe.domain.auth.service.query.TokenQueryService; +import org.withtime.be.withtimebe.global.security.constants.AuthenticationConstants; +import org.withtime.be.withtimebe.global.security.domain.CustomUserDetails; +import org.withtime.be.withtimebe.global.util.CookieUtil; + +@Component +@RequiredArgsConstructor +public class CookieTokenManager implements TokenManager { + + private final TokenStorageCommandService tokenStorageCommandService; + private final TokenCommandService tokenCommandService; + private final TokenQueryService tokenQueryService; + + @Override + public void addToken(HttpServletRequest request, HttpServletResponse response, CustomUserDetails customUserDetails) { + AuthResponseDTO.Login loginResponse = tokenCommandService.createLoginToken(customUserDetails); + for (String token : new String[] {CookieUtil.getCookie(request, AuthenticationConstants.ACCESS_TOKEN_NAME), CookieUtil.getCookie(request, AuthenticationConstants.REFRESH_TOKEN_NAME)}) { + if (token != null) { + tokenStorageCommandService.addBlackList(token); + } + } + + CookieUtil.addCookie(request, response, AuthenticationConstants.ACCESS_TOKEN_NAME, loginResponse.accessToken(), (int) tokenQueryService.getAccessTokenExpiration().toSeconds()); + CookieUtil.addCookie(request, response, AuthenticationConstants.REFRESH_TOKEN_NAME, loginResponse.refreshToken(), (int) tokenQueryService.getRefreshTokenExpiration().toSeconds()); + tokenStorageCommandService.addRefreshToken(customUserDetails.getId(), loginResponse.refreshToken()); + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/manager/TokenManager.java b/src/main/java/org/withtime/be/withtimebe/global/security/manager/TokenManager.java new file mode 100644 index 0000000..38ad7b0 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/security/manager/TokenManager.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.global.security.manager; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.withtime.be.withtimebe.global.security.domain.CustomUserDetails; + +public interface TokenManager { + void addToken(HttpServletRequest request, HttpServletResponse response,CustomUserDetails customUserDetails); +} diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index ca7cabd..3f5e593 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -26,6 +26,45 @@ spring: auth: true starttls: enable: true + security: + oauth2: + client: + registration: + kakao: + authorization-grant-type: authorization_code + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URI} + client-authentication-method: client_secret_post + scope: + - account_email + naver: + authorization-grant-type: authorization_code + redirect-uri: ${NAVER_REDIRECT_URI} + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + scope: + - email + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URI} + scope: + - email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + naver: + authorization_uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/userinfo/v2/me jwt: secret: ${JWT_SECRET}