├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── mart-holiday-alarm-api ├── src │ ├── main │ │ ├── resources │ │ │ ├── static │ │ │ │ └── favicon.ico │ │ │ ├── banner.txt │ │ │ └── application.yml │ │ └── java │ │ │ └── com │ │ │ └── hongsi │ │ │ └── martholidayalarm │ │ │ ├── api │ │ │ ├── repository │ │ │ │ ├── MartHolidayRepository.java │ │ │ │ ├── MartHolidayRepositoryCustom.java │ │ │ │ ├── MartLocationRepository.java │ │ │ │ └── MartHolidayRepositoryImpl.java │ │ │ ├── exception │ │ │ │ ├── ResourceNotFoundException.java │ │ │ │ └── InvalidMartTypeException.java │ │ │ ├── controller │ │ │ │ ├── validator │ │ │ │ │ ├── ValidLatitudeValidator.java │ │ │ │ │ ├── ValidLongitudeValidator.java │ │ │ │ │ ├── ValidLatitude.java │ │ │ │ │ ├── ValidLongitude.java │ │ │ │ │ └── ValidLocationRangeValidator.java │ │ │ │ ├── converter │ │ │ │ │ └── MartTypeParameterConverter.java │ │ │ │ ├── advice │ │ │ │ │ ├── CommonControllerAdvice.java │ │ │ │ │ └── MartControllerAdvice.java │ │ │ │ └── MartController.java │ │ │ ├── dto │ │ │ │ ├── ApiResponseCode.java │ │ │ │ ├── mart │ │ │ │ │ ├── MartTypeDto.java │ │ │ │ │ ├── LocationParameter.java │ │ │ │ │ ├── LocationDto.java │ │ │ │ │ ├── MartSortParser.java │ │ │ │ │ ├── MartOrderParser.java │ │ │ │ │ ├── MartOrder.java │ │ │ │ │ └── MartDto.java │ │ │ │ ├── ApiResponse.java │ │ │ │ ├── ApiLog.java │ │ │ │ ├── ApiException.java │ │ │ │ └── ErrorMessages.java │ │ │ ├── config │ │ │ │ └── WebMvcConfig.java │ │ │ ├── interceptor │ │ │ │ └── ApiLogInterceptor.java │ │ │ └── service │ │ │ │ └── MartService.java │ │ │ └── ApiApplication.java │ └── test │ │ ├── resources │ │ ├── org │ │ │ └── springframework │ │ │ │ └── restdocs │ │ │ │ └── templates │ │ │ │ ├── common-response-fields.snippet │ │ │ │ ├── path-parameters.snippet │ │ │ │ ├── request-fields.snippet │ │ │ │ └── request-parameters.snippet │ │ └── application.yml │ │ ├── java │ │ └── com │ │ │ └── hongsi │ │ │ └── martholidayalarm │ │ │ └── api │ │ │ ├── docs │ │ │ ├── CommonDocumentController.java │ │ │ ├── CommonResponseFieldsSnippet.java │ │ │ ├── CommonApiDocumentConfigure.java │ │ │ └── CommonDocumentControllerTest.java │ │ │ └── controller │ │ │ └── MartDocumentDescriptor.java │ │ └── groovy │ │ └── com │ │ └── hongsi │ │ └── martholidayalarm │ │ └── api │ │ ├── dto │ │ └── mart │ │ │ ├── MartOrderTest.groovy │ │ │ ├── MartOrderParserTest.groovy │ │ │ └── MartSortParserTest.groovy │ │ └── repository │ │ └── MartLocationRepositoryTest.groovy └── build.gradle ├── execution-app.sh ├── mart-holiday-alarm-clients ├── mart-holiday-alarm-client-firebase │ ├── build.gradle │ └── src │ │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── hongsi │ │ │ └── martholidayalarm │ │ │ └── clients │ │ │ └── firebase │ │ │ ├── FirebaseApplication.java │ │ │ └── message │ │ │ └── FirebaseMessageSenderTest.java │ │ └── main │ │ └── java │ │ └── com │ │ └── hongsi │ │ └── martholidayalarm │ │ └── clients │ │ └── firebase │ │ ├── database │ │ └── domain │ │ │ └── FirebaseDatabaseWrapper.java │ │ ├── exception │ │ └── PushException.java │ │ ├── message │ │ ├── PushErrorCode.java │ │ └── FirebaseMessageSender.java │ │ └── FirebaseAppInitializer.java ├── mart-holiday-alarm-client-slack │ ├── build.gradle │ └── src │ │ ├── main │ │ └── java │ │ │ └── com │ │ │ └── hongsi │ │ │ └── martholidayalarm │ │ │ └── client │ │ │ └── slack │ │ │ ├── model │ │ │ ├── Color.java │ │ │ ├── SlackChannel.java │ │ │ ├── Emoji.java │ │ │ ├── SlackTextGenerator.java │ │ │ └── SlackMessage.java │ │ │ └── SlackNotifier.java │ │ └── test │ │ └── java │ │ └── com │ │ └── hongsi │ │ └── martholidayalarm │ │ └── client │ │ └── slack │ │ └── SlackNotifierTest.java └── mart-holiday-alarm-client-location-converter │ ├── build.gradle │ └── src │ ├── test │ └── groovy │ │ └── com │ │ └── hongsi │ │ └── martholidayalarm │ │ └── client │ │ └── location │ │ └── converter │ │ └── LocationConverterTest.groovy │ └── main │ └── java │ └── com │ └── hongsi │ └── martholidayalarm │ └── client │ └── location │ └── converter │ ├── LocationConverter.java │ ├── kakao │ ├── KakaoLocationSearchItem.java │ ├── KakaoLocationSearchResult.java │ └── KakaoLocationConverter.java │ ├── LocationConverterProperties.java │ ├── LocationConversion.java │ └── config │ └── ClientConfig.java ├── appspec.yml ├── mart-holiday-alarm-push ├── src │ ├── test │ │ └── groovy │ │ │ └── com │ │ │ └── hongsi │ │ │ └── martholidayalarm │ │ │ ├── PushApplication.groovy │ │ │ └── push │ │ │ ├── model │ │ │ ├── PushCounterTest.groovy │ │ │ └── PushResultTest.groovy │ │ │ └── service │ │ │ └── MartPushAsyncServiceTest.groovy │ └── main │ │ └── java │ │ └── com │ │ └── hongsi │ │ └── martholidayalarm │ │ └── push │ │ ├── repository │ │ ├── PushMartRepository.java │ │ ├── PushMartRepositoryCustom.java │ │ ├── PushUserRepository.java │ │ └── PushMartRepositoryImpl.java │ │ ├── utils │ │ ├── TaskInfo.java │ │ └── StopWatch.java │ │ ├── model │ │ ├── PushResult.java │ │ ├── PushCounter.java │ │ ├── PushUser.java │ │ ├── PushMart.java │ │ └── MartPushMessage.java │ │ ├── config │ │ └── PushConfig.java │ │ ├── MartPushScheduler.java │ │ └── service │ │ ├── MartPusher.java │ │ └── MartPushAsyncService.java └── build.gradle ├── mart-holiday-alarm-crawler ├── src │ ├── main │ │ └── java │ │ │ └── com │ │ │ └── hongsi │ │ │ └── martholidayalarm │ │ │ └── crawler │ │ │ ├── MartCrawler.java │ │ │ ├── utils │ │ │ ├── TaskInfo.java │ │ │ ├── PhoneValidator.java │ │ │ ├── RegionParser.java │ │ │ ├── ApplicationContextUtils.java │ │ │ ├── HtmlParser.java │ │ │ ├── JsonParser.java │ │ │ └── MatchSpliterator.java │ │ │ ├── model │ │ │ ├── emart │ │ │ │ ├── EmartCrawler.java │ │ │ │ ├── EmartTradersCrawler.java │ │ │ │ ├── NobrandCrawler.java │ │ │ │ └── EmartHolidayParser.java │ │ │ ├── homeplus │ │ │ │ ├── HomePlusCrawler.java │ │ │ │ └── HomePlusExpressCrawler.java │ │ │ ├── costco │ │ │ │ └── CostcoCrawler.java │ │ │ ├── holiday │ │ │ │ ├── LocalDateRange.java │ │ │ │ ├── KoreanDayOfWeek.java │ │ │ │ ├── RegularHoliday.java │ │ │ │ ├── RegularHolidayParser.java │ │ │ │ ├── KoreanWeek.java │ │ │ │ ├── MonthDayHoliday.java │ │ │ │ └── RegularHolidayGenerator.java │ │ │ ├── MartParser.java │ │ │ └── lottemart │ │ │ │ └── LotteMartCrawler.java │ │ │ ├── exception │ │ │ ├── CrawlerDataParseException.java │ │ │ ├── CrawlerNotFoundException.java │ │ │ └── PageNotFoundException.java │ │ │ ├── domain │ │ │ ├── InvalidCrawledMartRepository.java │ │ │ └── InvalidCrawledMart.java │ │ │ ├── MartCrawlerAsyncService.java │ │ │ ├── config │ │ │ └── SchedulerConfig.java │ │ │ ├── MartCrawlerScheduler.java │ │ │ ├── MartCrawlerType.java │ │ │ └── MartCrawlerService.java │ └── test │ │ └── groovy │ │ └── com │ │ └── hongsi │ │ └── martholidayalarm │ │ └── crawler │ │ ├── model │ │ ├── emart │ │ │ ├── EmartCrawlerTest.groovy │ │ │ ├── NobrandCrawlerTest.groovy │ │ │ └── EmartTradersCrawlerTest.groovy │ │ ├── costco │ │ │ ├── CostcoLunarCalendarTest.groovy │ │ │ └── CostcoCrawlerTest.groovy │ │ ├── homeplus │ │ │ ├── HomePlusCrawlerTest.groovy │ │ │ └── HomePlusExpressCrawlerTest.groovy │ │ ├── lottemart │ │ │ └── LotteMartCrawlerTest.groovy │ │ └── holiday │ │ │ ├── KoreanWeekTest.groovy │ │ │ ├── RegularHolidayParserTest.groovy │ │ │ ├── KoreanDayOfWeekTest.groovy │ │ │ ├── RegularHolidayTest.groovy │ │ │ ├── LocalDateRangeTest.groovy │ │ │ └── MonthDayHolidayTest.groovy │ │ ├── utils │ │ ├── PhoneValidatorTest.groovy │ │ ├── RegionParserTest.groovy │ │ └── MatchSpliteratorTest.groovy │ │ └── domain │ │ └── InvalidCrawledMartTest.groovy └── build.gradle ├── mart-holiday-alarm-core ├── src │ ├── test │ │ ├── groovy │ │ │ └── com │ │ │ │ └── hongsi │ │ │ │ └── martholidayalarm │ │ │ │ └── core │ │ │ │ ├── CoreApplication.java │ │ │ │ ├── holiday │ │ │ │ ├── HolidaysTest.groovy │ │ │ │ └── HolidayTest.groovy │ │ │ │ ├── mart │ │ │ │ └── MartTypeTest.groovy │ │ │ │ └── location │ │ │ │ └── LocationTest.groovy │ │ ├── resources │ │ │ └── application.yml │ │ └── java │ │ │ └── com │ │ │ └── hongsi │ │ │ └── martholidayalarm │ │ │ └── core │ │ │ └── mart │ │ │ └── MartRepositoryTest.java │ └── main │ │ └── java │ │ └── com │ │ └── hongsi │ │ └── martholidayalarm │ │ └── core │ │ ├── mart │ │ ├── MartRepository.java │ │ └── MartType.java │ │ ├── exception │ │ └── LocationOutOfRangeException.java │ │ ├── holiday │ │ ├── Holidays.java │ │ └── Holiday.java │ │ ├── BaseEntity.java │ │ ├── location │ │ └── Location.java │ │ └── BaseQuerydslRepositorySupport.java └── build.gradle ├── .gitignore ├── settings.gradle ├── README.md ├── gradlew.bat └── .github └── workflows └── build.yml /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hongsii/mart-holiday-alarm/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/resources/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hongsii/mart-holiday-alarm/HEAD/mart-holiday-alarm-api/src/main/resources/static/favicon.ico -------------------------------------------------------------------------------- /execution-app.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | app_dir=/home/ubuntu/app/MartHolidayAlarm 3 | app_log_path=$app_dir/logs 4 | mkdir -p $app_log_path 5 | $app_dir/deploy.sh > $app_log_path/execution.log 2> /dev/null < /dev/null & -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-firebase/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | compile("com.google.firebase:firebase-admin:6.4.0") 3 | } 4 | 5 | bootJar.enabled = false 6 | jar.enabled = true 7 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-slack/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation("org.springframework.boot:spring-boot-starter-web") 3 | } 4 | 5 | bootJar.enabled = false 6 | jar.enabled = true 7 | -------------------------------------------------------------------------------- /appspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.0 2 | os: linux 3 | files: 4 | - source: / 5 | destination: /home/ubuntu/app/MartHolidayAlarm/source/ 6 | 7 | hooks: 8 | AfterInstall: 9 | - location: execution-app.sh 10 | timeout: 600 -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-location-converter/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation("org.springframework.boot:spring-boot-starter-web") 3 | } 4 | 5 | bootJar.enabled = false 6 | jar.enabled = true 7 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/test/groovy/com/hongsi/martholidayalarm/PushApplication.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | 5 | @SpringBootApplication 6 | class PushApplication { 7 | } 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Mar 20 03:17:39 KST 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip 7 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/resources/org/springframework/restdocs/templates/common-response-fields.snippet: -------------------------------------------------------------------------------- 1 | {{title}} 2 | |=== 3 | |필드명|설명 4 | 5 | {{#fields}} 6 | |{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} 7 | |{{#tableCellContent}}{{description}}{{/tableCellContent}} 8 | 9 | {{/fields}} 10 | 11 | |=== 12 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/repository/MartHolidayRepository.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.repository; 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartRepository; 4 | 5 | public interface MartHolidayRepository extends MartRepository, MartHolidayRepositoryCustom { 6 | } 7 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/repository/PushMartRepository.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.repository; 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartRepository; 4 | 5 | public interface PushMartRepository extends MartRepository, PushMartRepositoryCustom { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/MartCrawler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler; 2 | 3 | import com.hongsi.martholidayalarm.crawler.model.MartParser; 4 | 5 | import java.util.List; 6 | 7 | public interface MartCrawler { 8 | 9 | List crawl(); 10 | } 11 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/resources/org/springframework/restdocs/templates/path-parameters.snippet: -------------------------------------------------------------------------------- 1 | ===== Path Parameters 2 | |=== 3 | | 파라미터명 | 설명 4 | 5 | 6 | {{#parameters}} 7 | |{{#tableCellContent}}{{path}}{{/tableCellContent}} 8 | |{{#tableCellContent}}{{description}}{{/tableCellContent}} 9 | 10 | {{/parameters}} 11 | 12 | |=== 13 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-firebase/src/test/java/com/hongsi/martholidayalarm/clients/firebase/FirebaseApplication.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.clients.firebase; 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication; 4 | 5 | @SpringBootApplication 6 | public class FirebaseApplication { 7 | } 8 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: 3 | active: test 4 | 5 | --- 6 | 7 | spring: 8 | profiles: test 9 | jpa: 10 | show-sql: true 11 | properties: 12 | hibernate: 13 | format_sql: true 14 | output: 15 | ansi.enabled: always 16 | 17 | # logging.level.org.hibernate.type: trace 18 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | compile project(":mart-holiday-alarm-core") 3 | compile project(":mart-holiday-alarm-client-firebase") 4 | compile project(":mart-holiday-alarm-client-slack") 5 | 6 | implementation("org.springframework.boot:spring-boot-starter-web") 7 | } 8 | 9 | bootJar.enabled = false 10 | jar.enabled = true 11 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/utils/TaskInfo.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.utils; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @AllArgsConstructor 7 | @Getter 8 | public class TaskInfo { 9 | 10 | private final String name; 11 | private final long elapsedTime; 12 | } 13 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-location-converter/src/test/groovy/com/hongsi/martholidayalarm/client/location/converter/LocationConverterTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.location.converter 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | 5 | @SpringBootApplication 6 | class LocationConverterTest { 7 | } 8 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/utils/TaskInfo.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | 6 | @AllArgsConstructor 7 | @Getter 8 | public class TaskInfo { 9 | 10 | private final String name; 11 | private final long elapsedTime; 12 | } 13 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/test/groovy/com/hongsi/martholidayalarm/core/CoreApplication.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core; 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication; 4 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing; 5 | 6 | @EnableJpaAuditing 7 | @SpringBootApplication 8 | public class CoreApplication { 9 | } 10 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/main/java/com/hongsi/martholidayalarm/core/mart/MartRepository.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.mart; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | import java.util.Optional; 6 | 7 | public interface MartRepository extends JpaRepository { 8 | 9 | Optional findByRealIdAndMartType(String realId, MartType martType); 10 | } 11 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/emart/EmartCrawler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.emart; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | @Component 6 | public class EmartCrawler extends EmartCommonCrawler { 7 | 8 | @Override 9 | protected SearchType getSearchType() { 10 | return SearchType.EMART; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/exception/ResourceNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.exception; 2 | 3 | public class ResourceNotFoundException extends RuntimeException { 4 | 5 | public ResourceNotFoundException() { 6 | this("Not found resource of request"); 7 | } 8 | 9 | public ResourceNotFoundException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-location-converter/src/main/java/com/hongsi/martholidayalarm/client/location/converter/LocationConverter.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.location.converter; 2 | 3 | 4 | import java.util.function.Supplier; 5 | 6 | public interface LocationConverter { 7 | 8 | LocationConversion convert(Supplier querySupplier, Supplier fallbackSupplier); 9 | } 10 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/exception/CrawlerDataParseException.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.exception; 2 | 3 | public class CrawlerDataParseException extends RuntimeException { 4 | 5 | public CrawlerDataParseException() { 6 | super("데이터를 파싱할 수 없습니다."); 7 | } 8 | 9 | public CrawlerDataParseException(Throwable cause) { 10 | super(cause); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | compile project(":mart-holiday-alarm-core") 3 | compile project(":mart-holiday-alarm-client-location-converter") 4 | compile project(":mart-holiday-alarm-client-slack") 5 | 6 | implementation "org.springframework.boot:spring-boot-starter-web" 7 | 8 | implementation "org.jsoup:jsoup:1.10.2" 9 | } 10 | 11 | bootJar.enabled = false 12 | jar.enabled = true 13 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/emart/EmartTradersCrawler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.emart; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | @Component 6 | public class EmartTradersCrawler extends EmartCommonCrawler { 7 | 8 | @Override 9 | protected SearchType getSearchType() { 10 | return SearchType.TRADERS; 11 | } 12 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/domain/InvalidCrawledMartRepository.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.domain; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | import java.util.List; 6 | 7 | public interface InvalidCrawledMartRepository extends JpaRepository { 8 | 9 | List findAllByEnable(boolean enable); 10 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/controller/validator/ValidLatitudeValidator.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller.validator; 2 | 3 | import com.hongsi.martholidayalarm.core.location.Location; 4 | 5 | public class ValidLatitudeValidator extends ValidLocationRangeValidator { 6 | 7 | public ValidLatitudeValidator() { 8 | super(Location.Range.Latitude); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/exception/CrawlerNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.exception; 2 | 3 | public class CrawlerNotFoundException extends Exception { 4 | 5 | public CrawlerNotFoundException() { 6 | this("해당 마트의 크롤러가 존재하지 않습니다."); 7 | } 8 | 9 | public CrawlerNotFoundException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/repository/PushMartRepositoryCustom.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.repository; 2 | 3 | import com.hongsi.martholidayalarm.push.model.PushMart; 4 | 5 | import java.time.LocalDate; 6 | import java.util.List; 7 | 8 | public interface PushMartRepositoryCustom { 9 | 10 | List findAllByIdInAndHolidayDate(List ids, LocalDate holidayDate); 11 | } 12 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/controller/validator/ValidLongitudeValidator.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller.validator; 2 | 3 | import com.hongsi.martholidayalarm.core.location.Location; 4 | 5 | public class ValidLongitudeValidator extends ValidLocationRangeValidator { 6 | 7 | public ValidLongitudeValidator() { 8 | super(Location.Range.Longitude); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .gradle 3 | /build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | out/ 6 | /src/main/generated/ 7 | generated/ 8 | /logs 9 | 10 | ### STS ### 11 | .apt_generated 12 | .classpath 13 | .factorypath 14 | .project 15 | .settings 16 | .springBeans 17 | 18 | ### IntelliJ IDEA ### 19 | .idea 20 | *.iws 21 | *.iml 22 | *.ipr 23 | 24 | ### NetBeans ### 25 | nbproject/private/ 26 | build/ 27 | nbbuild/ 28 | dist/ 29 | nbdist/ 30 | .nb-gradle/ 31 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-slack/src/main/java/com/hongsi/martholidayalarm/client/slack/model/Color.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.slack.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonValue; 4 | import lombok.AllArgsConstructor; 5 | 6 | @AllArgsConstructor 7 | public enum Color { 8 | 9 | RED("#FF0000"), 10 | LIME("#00FF00"); 11 | 12 | @JsonValue 13 | private final String hex; 14 | } 15 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-slack/src/main/java/com/hongsi/martholidayalarm/client/slack/model/SlackChannel.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.slack.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonValue; 4 | import lombok.AllArgsConstructor; 5 | 6 | @AllArgsConstructor 7 | public enum SlackChannel { 8 | 9 | CRAWLING_ALARM("CN4LW9R16"); 10 | 11 | @JsonValue 12 | private final String id; 13 | } 14 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/exception/PageNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.exception; 2 | 3 | public class PageNotFoundException extends RuntimeException { 4 | 5 | public PageNotFoundException() { 6 | super("존재하지 않는 사이트이거나, 사이트 내 정보가 존재하지 않습니다."); 7 | } 8 | 9 | public PageNotFoundException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet: -------------------------------------------------------------------------------- 1 | ===== Request Fields 2 | |=== 3 | | 필드명 | 타입 | 포맷 | 설명 4 | 5 | {{#fields}} 6 | |{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} 7 | |{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} 8 | |{{#tableCellContent}}{{#format}}{{.}}{{/format}}{{/tableCellContent}} 9 | |{{#tableCellContent}}{{description}}{{/tableCellContent}} 10 | 11 | {{/fields}} 12 | 13 | |=== -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/homeplus/HomePlusCrawler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.homeplus; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.stereotype.Component; 5 | 6 | @Component 7 | @Slf4j 8 | public class HomePlusCrawler extends HomePlusCommonCrawler { 9 | 10 | @Override 11 | protected StoreType getStoreType() { 12 | return StoreType.HOMEPLUS; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-slack/src/main/java/com/hongsi/martholidayalarm/client/slack/model/Emoji.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.slack.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonValue; 4 | 5 | public enum Emoji { 6 | 7 | FIRE, ZAP, SUNNY; 8 | 9 | private static final String AFFIX = ":"; 10 | 11 | @JsonValue 12 | @Override 13 | public String toString() { 14 | return AFFIX + name() + AFFIX; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/homeplus/HomePlusExpressCrawler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.homeplus; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.stereotype.Component; 5 | 6 | @Component 7 | @Slf4j 8 | public class HomePlusExpressCrawler extends HomePlusCommonCrawler { 9 | 10 | @Override 11 | protected StoreType getStoreType() { 12 | return StoreType.EXPRESS; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/ApiResponseCode.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto; 2 | 3 | import lombok.Getter; 4 | import lombok.RequiredArgsConstructor; 5 | 6 | @RequiredArgsConstructor 7 | @Getter 8 | public enum ApiResponseCode { 9 | 10 | OK("요청이 성공했습니다."), 11 | BAD_PARAMETER("잘못된 요청파라미터입니다."), 12 | BAD_REQUEST("잘못된 요청입니다."), 13 | NOT_FOUND("요청한 리소스를 찾을 수 없습니다."), 14 | SERVER_ERROR("서버 에러가 발생 중입니다."); 15 | 16 | private final String message; 17 | } 18 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | hikari: 4 | driver-class-name: org.h2.Driver 5 | jdbc-url: jdbc:h2:mem://localhost/~/martholidayalarm;MODE=MYSQL 6 | username: sa 7 | password: 8 | jpa: 9 | database-platform: H2 10 | show-sql: true 11 | open-in-view: false 12 | hibernate: 13 | ddl-auto: update 14 | 15 | output: 16 | ansi: 17 | enabled: always 18 | 19 | #logging.level.org.hibernate.type.descriptor.sql: trace -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/resources/org/springframework/restdocs/templates/request-parameters.snippet: -------------------------------------------------------------------------------- 1 | ===== Request Parameters 2 | |=== 3 | | 파라미터명 | 필수 | 포맷 | 기본 설정 | 설명 4 | 5 | 6 | {{#parameters}} 7 | |{{#tableCellContent}}{{name}}{{/tableCellContent}} 8 | |{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}} 9 | |{{#tableCellContent}}{{#format}}{{.}}{{/format}}{{/tableCellContent}} 10 | |{{#tableCellContent}}{{#default}}{{.}}{{/default}}{{/tableCellContent}} 11 | |{{#tableCellContent}}{{description}}{{/tableCellContent}} 12 | 13 | {{/parameters}} 14 | 15 | |=== 16 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/utils/PhoneValidator.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils; 2 | 3 | import java.util.regex.Matcher; 4 | import java.util.regex.Pattern; 5 | 6 | public class PhoneValidator { 7 | 8 | public static final Pattern PHONE_NUMBER_PATTERN = Pattern 9 | .compile("^(\\+\\d{1,3}|[\\d]{2,3})-\\d{3,4}-\\d{4}$"); 10 | 11 | public static boolean isValid(String phoneNumber) { 12 | Matcher matcher = PHONE_NUMBER_PATTERN.matcher(phoneNumber); 13 | return matcher.matches(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-firebase/src/main/java/com/hongsi/martholidayalarm/clients/firebase/database/domain/FirebaseDatabaseWrapper.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.clients.firebase.database.domain; 2 | 3 | import com.google.firebase.database.DatabaseReference; 4 | 5 | import static com.google.firebase.database.FirebaseDatabase.getInstance; 6 | 7 | public class FirebaseDatabaseWrapper { 8 | 9 | public static final String DEFAULT_NODE = "/"; 10 | 11 | public static DatabaseReference root() { 12 | return getInstance().getReference(DEFAULT_NODE); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | 2 | ███╗ ███╗ █████╗ ██████╗ ████████╗ ██╗ ██╗ ██████╗ ██╗ ██╗██████╗ █████╗ ██╗ ██╗ 3 | ████╗ ████║██╔══██╗██╔══██╗╚══██╔══╝ ██║ ██║██╔═══██╗██║ ██║██╔══██╗██╔══██╗╚██╗ ██╔╝ 4 | ██╔████╔██║███████║██████╔╝ ██║ ███████║██║ ██║██║ ██║██║ ██║███████║ ╚████╔╝ 5 | ██║╚██╔╝██║██╔══██║██╔══██╗ ██║ ██╔══██║██║ ██║██║ ██║██║ ██║██╔══██║ ╚██╔╝ 6 | ██║ ╚═╝ ██║██║ ██║██║ ██║ ██║ ██║ ██║╚██████╔╝███████╗██║██████╔╝██║ ██║ ██║ 7 | ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ 8 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | } 6 | 7 | rootProject.name = "mart-holiday-alarm" 8 | 9 | include("mart-holiday-alarm-api") 10 | include("mart-holiday-alarm-core", "mart-holiday-alarm-crawler", "mart-holiday-alarm-push") 11 | include("mart-holiday-alarm-client-firebase", "mart-holiday-alarm-client-location-converter", "mart-holiday-alarm-client-slack") 12 | 13 | rootProject.children 14 | .stream() 15 | .filter { it.name.startsWith("mart-holiday-alarm-client") } 16 | .forEach { it.projectDir = file("mart-holiday-alarm-clients/$it.name") } 17 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/mart/MartTypeDto.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart; 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType; 4 | import lombok.AccessLevel; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 10 | public class MartTypeDto { 11 | 12 | private String value; 13 | private String displayName; 14 | 15 | public MartTypeDto(MartType martType) { 16 | value = martType.name().toLowerCase(); 17 | displayName = martType.getName(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/controller/validator/ValidLatitude.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller.validator; 2 | 3 | import javax.validation.Constraint; 4 | import javax.validation.Payload; 5 | import java.lang.annotation.*; 6 | 7 | @Documented 8 | @Target(ElementType.FIELD) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | @Constraint(validatedBy = ValidLatitudeValidator.class) 11 | public @interface ValidLatitude { 12 | 13 | String message() default "Invalid latitude"; 14 | 15 | Class[] groups() default {}; 16 | 17 | Class[] payload() default {}; 18 | } 19 | 20 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/controller/validator/ValidLongitude.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller.validator; 2 | 3 | import javax.validation.Constraint; 4 | import javax.validation.Payload; 5 | import java.lang.annotation.*; 6 | 7 | @Documented 8 | @Target(ElementType.FIELD) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | @Constraint(validatedBy = ValidLongitudeValidator.class) 11 | public @interface ValidLongitude { 12 | 13 | String message() default "Invalid longitude"; 14 | 15 | Class[] groups() default {}; 16 | 17 | Class[] payload() default {}; 18 | } 19 | 20 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/emart/EmartCrawlerTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.emart 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType 4 | import spock.lang.Specification 5 | 6 | class EmartCrawlerTest extends Specification { 7 | 8 | def "crawl"() { 9 | given: 10 | def crawler = new EmartCrawler() 11 | 12 | when: 13 | def marts = crawler.crawl() 14 | 15 | then: 16 | !marts.isEmpty() 17 | marts.every { it.getMartType() == MartType.EMART } 18 | marts.each { println it.toString() } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/emart/NobrandCrawlerTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.emart 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType 4 | import spock.lang.Specification 5 | 6 | class NobrandCrawlerTest extends Specification { 7 | 8 | def "crawl"() { 9 | given: 10 | def crawler = new NobrandCrawler() 11 | 12 | when: 13 | def marts = crawler.crawl() 14 | 15 | then: 16 | !marts.isEmpty() 17 | marts.every { it.getMartType() == MartType.NOBRAND } 18 | marts.each { println it.toString() } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/java/com/hongsi/martholidayalarm/api/docs/CommonDocumentController.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.docs; 2 | 3 | import com.hongsi.martholidayalarm.api.dto.ApiResponse; 4 | import org.springframework.web.bind.annotation.GetMapping; 5 | import org.springframework.web.bind.annotation.RestController; 6 | 7 | import javax.validation.Valid; 8 | 9 | @RestController 10 | public class CommonDocumentController { 11 | 12 | @GetMapping("/docs") 13 | public ApiResponse docsHome() { 14 | return ApiResponse.ok("response wrapper"); 15 | } 16 | 17 | @GetMapping("/docs/error") 18 | public void docsHome(@Valid Double value) { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/costco/CostcoLunarCalendarTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.costco 2 | 3 | import spock.lang.Specification 4 | 5 | import java.time.LocalDate 6 | 7 | class CostcoLunarCalendarTest extends Specification { 8 | 9 | def "convert lunar to solar"() { 10 | expect: 11 | CostcoParser.CostcoLunarCalendar.convertToSolar(lunar) == solar 12 | 13 | where: 14 | lunar || solar 15 | LocalDate.of(2025, 1, 1) || LocalDate.of(2025, 1, 29) 16 | LocalDate.of(2025, 8, 15) || LocalDate.of(2025, 10, 6) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/homeplus/HomePlusCrawlerTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.homeplus 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType 4 | import spock.lang.Specification 5 | 6 | class HomePlusCrawlerTest extends Specification { 7 | 8 | def "crawl"() { 9 | given: 10 | def crawler = new HomePlusCrawler() 11 | 12 | when: 13 | def marts = crawler.crawl() 14 | 15 | then: 16 | !marts.isEmpty() 17 | marts.every { it.getMartType() == MartType.HOMEPLUS } 18 | marts.each { println it.toString() } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/lottemart/LotteMartCrawlerTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.lottemart 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType 4 | import spock.lang.Specification 5 | 6 | class LotteMartCrawlerTest extends Specification { 7 | 8 | def "crawl"() { 9 | given: 10 | def crawler = new LotteMartCrawler() 11 | 12 | when: 13 | def marts = crawler.crawl() 14 | 15 | then: 16 | !marts.isEmpty() 17 | marts.every { it.getMartType() == MartType.LOTTEMART } 18 | marts.each { println it.toString() } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/emart/EmartTradersCrawlerTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.emart 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType 4 | import spock.lang.Specification 5 | 6 | class EmartTradersCrawlerTest extends Specification { 7 | 8 | def "crawl"() { 9 | given: 10 | def crawler = new EmartTradersCrawler() 11 | 12 | when: 13 | def marts = crawler.crawl() 14 | 15 | then: 16 | !marts.isEmpty() 17 | marts.every { it.getMartType() == MartType.EMART_TRADERS } 18 | marts.each { println it.toString() } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-firebase/src/main/java/com/hongsi/martholidayalarm/clients/firebase/exception/PushException.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.clients.firebase.exception; 2 | 3 | import com.hongsi.martholidayalarm.clients.firebase.message.PushErrorCode; 4 | 5 | public class PushException extends RuntimeException { 6 | 7 | private final PushErrorCode errorCode; 8 | 9 | public PushException(String errorCode, String message) { 10 | super(message); 11 | this.errorCode = PushErrorCode.of(errorCode); 12 | } 13 | 14 | public boolean isDeletedToken() { 15 | return errorCode == PushErrorCode.DELETED_TOKEN; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/homeplus/HomePlusExpressCrawlerTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.homeplus 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType 4 | import spock.lang.Specification 5 | 6 | class HomePlusExpressCrawlerTest extends Specification { 7 | 8 | def "crawl"() { 9 | given: 10 | def crawler = new HomePlusExpressCrawler() 11 | 12 | when: 13 | def marts = crawler.crawl() 14 | 15 | then: 16 | !marts.isEmpty() 17 | marts.every { it.getMartType() == MartType.HOMEPLUS_EXPRESS } 18 | marts.each { println it.toString() } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/costco/CostcoCrawlerTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.costco 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType 4 | import spock.lang.Specification 5 | 6 | class CostcoCrawlerTest extends Specification { 7 | 8 | def "crawl"() { 9 | given: 10 | def crawler = new CostcoCrawler() 11 | 12 | when: 13 | def marts = crawler.crawl() 14 | 15 | then: 16 | !marts.isEmpty() 17 | marts.every { it.getMartType() == MartType.COSTCO } 18 | marts.each { println it.toString() } 19 | marts.each { println it.holidays } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-slack/src/main/java/com/hongsi/martholidayalarm/client/slack/model/SlackTextGenerator.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.slack.model; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.NoArgsConstructor; 5 | 6 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 7 | public class SlackTextGenerator { 8 | 9 | private static final String AFFIX_CODE_BLOCK = "```"; 10 | private static final String NEW_LINE = "\n"; 11 | 12 | public static String newLine(String text) { 13 | return text + NEW_LINE; 14 | } 15 | 16 | public static String codeBlock(String text) { 17 | return AFFIX_CODE_BLOCK + text + AFFIX_CODE_BLOCK; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/main/java/com/hongsi/martholidayalarm/core/exception/LocationOutOfRangeException.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.exception; 2 | 3 | import com.hongsi.martholidayalarm.core.location.Location; 4 | 5 | public class LocationOutOfRangeException extends RuntimeException { 6 | 7 | private static final String INVALID_MESSAGE_FORMAT = "%s는 %.0f과 %.0f 사이여야 합니다."; 8 | 9 | public LocationOutOfRangeException(Location.Range range) { 10 | super(getMessageWithTemplate(range)); 11 | } 12 | 13 | public static String getMessageWithTemplate(Location.Range range) { 14 | return String.format(INVALID_MESSAGE_FORMAT, range.getName(), range.getMin(), range.getMax()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/ApiResponse.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class ApiResponse { 7 | 8 | private ApiResponseCode code; 9 | private String message; 10 | private T data; 11 | 12 | private ApiResponse(ApiResponseCode code, String message, T data) { 13 | this.code = code; 14 | this.message = message; 15 | this.data = data; 16 | } 17 | 18 | public static ApiResponse ok(T data) { 19 | return ApiResponse.of(ApiResponseCode.OK, data); 20 | } 21 | 22 | public static ApiResponse of(ApiResponseCode code, T data) { 23 | return new ApiResponse<>(code, code.getMessage(), data); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/test/groovy/com/hongsi/martholidayalarm/core/holiday/HolidaysTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.holiday 2 | 3 | import spock.lang.Specification 4 | 5 | import java.time.LocalDate 6 | 7 | class HolidaysTest extends Specification { 8 | 9 | def "다가오는 휴일만 조회할 수 있다"() { 10 | given: 11 | def yesterday = Holiday.of(LocalDate.now().minusDays(1)) 12 | def today = Holiday.of(LocalDate.now()) 13 | def tomorrow = Holiday.of(LocalDate.now().plusDays(1)) 14 | 15 | when: 16 | def holidays = Holidays.of([yesterday, today, tomorrow]) 17 | def actual = holidays.upcomingHolidays 18 | 19 | then: 20 | actual == [today, tomorrow] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/utils/RegionParser.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils; 2 | 3 | import java.util.regex.Matcher; 4 | import java.util.regex.Pattern; 5 | 6 | public class RegionParser { 7 | 8 | private static final Pattern REPLACING_REGION_PATTERN = Pattern.compile("(경상|충청|전라)(남도|북도)"); 9 | 10 | public static String getRegionFromAddress(String address) { 11 | String region = address.substring(0, 2); 12 | Matcher matcher = REPLACING_REGION_PATTERN.matcher(address); 13 | if (matcher.find()) { 14 | region = matcher.group(1).substring(0, 1) + matcher.group(2).substring(0, 1); 15 | } 16 | return region; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/mart/LocationParameter.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart; 2 | 3 | import com.hongsi.martholidayalarm.api.controller.validator.ValidLatitude; 4 | import com.hongsi.martholidayalarm.api.controller.validator.ValidLongitude; 5 | import lombok.Data; 6 | 7 | import javax.validation.constraints.Max; 8 | import javax.validation.constraints.Min; 9 | 10 | @Data 11 | public class LocationParameter { 12 | 13 | private static final int DEFAULT_DISTANCE = 3; 14 | 15 | @ValidLatitude 16 | private Double latitude; 17 | 18 | @ValidLongitude 19 | private Double longitude; 20 | 21 | @Max(50) 22 | @Min(1) 23 | private Integer distance = DEFAULT_DISTANCE; 24 | } 25 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-firebase/src/main/java/com/hongsi/martholidayalarm/clients/firebase/message/PushErrorCode.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.clients.firebase.message; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | 5 | import java.util.Arrays; 6 | 7 | @RequiredArgsConstructor 8 | public enum PushErrorCode { 9 | 10 | DELETED_TOKEN("registration-token-not-registered"), ERROR("unknown error"); 11 | 12 | private final String errorCode; 13 | 14 | public static PushErrorCode of(String errorCode) { 15 | return Arrays.stream(values()) 16 | .filter(pushErrorCode -> pushErrorCode.errorCode.equalsIgnoreCase(errorCode)) 17 | .findFirst() 18 | .orElse(ERROR); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/main/java/com/hongsi/martholidayalarm/core/mart/MartType.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.mart; 2 | 3 | import lombok.Getter; 4 | 5 | import java.util.Arrays; 6 | 7 | @Getter 8 | public enum MartType { 9 | 10 | EMART("이마트"), 11 | EMART_TRADERS("이마트 트레이더스"), 12 | NOBRAND("노브랜드"), 13 | LOTTEMART("롯데마트"), 14 | HOMEPLUS("홈플러스"), 15 | HOMEPLUS_EXPRESS("홈플러스 익스프레스"), 16 | COSTCO("코스트코"); 17 | 18 | private String name; 19 | 20 | MartType(String name) { 21 | this.name = name; 22 | } 23 | 24 | public static MartType of(String name) { 25 | return Arrays.stream(values()) 26 | .filter(martType -> martType.name.equals(name)) 27 | .findFirst() 28 | .orElseThrow(() -> new IllegalArgumentException("잘못된 마트명입니다.")); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/utils/PhoneValidatorTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils 2 | 3 | 4 | import spock.lang.Specification 5 | import spock.lang.Unroll 6 | 7 | class PhoneValidatorTest extends Specification { 8 | 9 | @Unroll 10 | def "Should validate phone number"() { 11 | expect: 12 | PhoneValidator.isValid(phoneNumber) == expected 13 | 14 | where: 15 | phoneNumber || expected 16 | "02-1234-4560" || true 17 | "051-1234-4560" || true 18 | "051-123-4560" || true 19 | "+82-1899-9900" || true 20 | 21 | "02-12-4560" || false 22 | "02-123-456" || false 23 | "0233-123-4567" || false 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/utils/RegionParserTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils 2 | 3 | 4 | import spock.lang.Specification 5 | import spock.lang.Unroll 6 | 7 | class RegionParserTest extends Specification { 8 | 9 | @Unroll 10 | def "Should parse region"() { 11 | expect: 12 | RegionParser.getRegionFromAddress(region) == expected 13 | 14 | where: 15 | region || expected 16 | "서울광역시" || "서울" 17 | "부산광역시" || "부산" 18 | "경기도" || "경기" 19 | "강원도" || "강원" 20 | "경상북도" || "경북" 21 | "경상남도" || "경남" 22 | "충청북도" || "충북" 23 | "충청남도" || "충남" 24 | "전라북도" || "전북" 25 | "전라남도" || "전남" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/ApiLog.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto; 2 | 3 | import lombok.Builder; 4 | import lombok.ToString; 5 | 6 | import java.util.Objects; 7 | 8 | @ToString 9 | public class ApiLog { 10 | 11 | private final String remoteAddr; 12 | private final String httpMethod; 13 | private final String urlPattern; 14 | private final String queryString; 15 | 16 | @Builder 17 | public ApiLog(String remoteAddr, String httpMethod, String urlPattern, String queryString) { 18 | this.remoteAddr = remoteAddr; 19 | this.httpMethod = httpMethod; 20 | this.urlPattern = urlPattern; 21 | this.queryString = String.format("'%s'", Objects.toString(queryString, "")); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | hikari: 4 | driver-class-name: org.mariadb.jdbc.Driver 5 | minimumIdle: 5 6 | maximumPoolSize: 15 7 | idleTimeout: 600000 8 | maxLifetime: 1800000 9 | data-source-properties: 10 | connect-timeout: 30000 11 | socket-timeout: 60000 12 | 13 | jpa: 14 | hibernate: 15 | ddl-auto: none 16 | open-in-view: false 17 | 18 | --- 19 | 20 | spring: 21 | profiles: local 22 | 23 | jpa: 24 | hibernate: 25 | ddl-auto: none 26 | show-sql: true 27 | 28 | --- 29 | 30 | spring: 31 | profiles: prod1,dev1 32 | 33 | server: 34 | port: 8081 35 | 36 | --- 37 | 38 | spring: 39 | profiles: prod2,dev2 40 | 41 | server: 42 | port: 8082 43 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/test/groovy/com/hongsi/martholidayalarm/push/model/PushCounterTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.model 2 | 3 | 4 | import spock.lang.Specification 5 | 6 | class PushCounterTest extends Specification { 7 | 8 | def "Should record push result"() { 9 | given: 10 | def counter = new PushCounter() 11 | def successCount = 2 12 | def failureCount = 3 13 | 14 | when: 15 | (1..successCount).forEach { counter.recordSuccess() } 16 | (1..failureCount).forEach { counter.recordFailure() } 17 | 18 | then: 19 | def expected = PushResult.builder() 20 | .successCount(successCount) 21 | .failureCount(failureCount) 22 | .build() 23 | counter.getPushResult() == expected 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/mart/LocationDto.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonInclude.Include; 5 | import com.hongsi.martholidayalarm.core.location.Location; 6 | import lombok.Data; 7 | 8 | import javax.annotation.Nullable; 9 | 10 | @Data 11 | public class LocationDto { 12 | 13 | private Double latitude; 14 | private Double longitude; 15 | @JsonInclude(Include.NON_NULL) 16 | private Double distance; 17 | 18 | public LocationDto(@Nullable Location location, @Nullable Double distance) { 19 | if (location != null) { 20 | this.latitude = location.getLatitude(); 21 | this.longitude = location.getLongitude(); 22 | } 23 | this.distance = distance; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-location-converter/src/main/java/com/hongsi/martholidayalarm/client/location/converter/kakao/KakaoLocationSearchItem.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.location.converter.kakao; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import lombok.Data; 5 | import org.springframework.util.StringUtils; 6 | 7 | @Data 8 | public class KakaoLocationSearchItem { 9 | 10 | @JsonProperty("road_address_name") 11 | private String roadAddress; 12 | @JsonProperty("address_name") 13 | private String address; 14 | @JsonProperty("y") 15 | private Double latitude; 16 | @JsonProperty("x") 17 | private Double longitude; 18 | 19 | public String getAddress() { 20 | return StringUtils.hasText(roadAddress) ? roadAddress : address; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/controller/converter/MartTypeParameterConverter.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller.converter; 2 | 3 | import com.hongsi.martholidayalarm.api.exception.InvalidMartTypeException; 4 | import com.hongsi.martholidayalarm.core.mart.MartType; 5 | 6 | import java.beans.PropertyEditorSupport; 7 | 8 | public class MartTypeParameterConverter extends PropertyEditorSupport { 9 | 10 | @Override 11 | public void setAsText(String text) throws IllegalArgumentException { 12 | try { 13 | String capitalized = text.toUpperCase(); 14 | MartType martType = MartType.valueOf(capitalized); 15 | setValue(martType); 16 | } catch (IllegalArgumentException e) { 17 | throw new InvalidMartTypeException(text); 18 | } 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-location-converter/src/main/java/com/hongsi/martholidayalarm/client/location/converter/LocationConverterProperties.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.location.converter; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Data 8 | @NoArgsConstructor 9 | public class LocationConverterProperties { 10 | 11 | private String clientId; 12 | private String clientSecret; 13 | private String searchUrl; 14 | private String addressUrl; 15 | 16 | @Builder 17 | public LocationConverterProperties(String clientId, String clientSecret, String searchUrl, String addressUrl) { 18 | this.clientId = clientId; 19 | this.clientSecret = clientSecret; 20 | this.searchUrl = searchUrl; 21 | this.addressUrl = addressUrl; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/build.gradle: -------------------------------------------------------------------------------- 1 | bootJar.enabled = false 2 | jar.enabled = true 3 | 4 | dependencies { 5 | compile "org.springframework.boot:spring-boot-starter-data-jpa" 6 | compile "com.querydsl:querydsl-core" 7 | compile "com.querydsl:querydsl-jpa" 8 | annotationProcessor( 9 | "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa", 10 | "jakarta.persistence:jakarta.persistence-api", 11 | "jakarta.annotation:jakarta.annotation-api" 12 | ) 13 | 14 | runtime "com.h2database:h2" 15 | compile "org.mariadb.jdbc:mariadb-java-client" 16 | } 17 | 18 | /** clean 태스크 실행시 QClass 삭제 */ 19 | clean { 20 | delete file('src/main/generated') 21 | } 22 | /** 인텔리제이 Annotation processor 에 생성되는 'src/main/generated' 디렉터리 삭제 */ 23 | task cleanGeneatedDir(type: Delete) { 24 | delete file('src/main/generated') 25 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/repository/MartHolidayRepositoryCustom.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.repository; 2 | 3 | import com.hongsi.martholidayalarm.api.dto.mart.MartDto; 4 | import com.hongsi.martholidayalarm.api.dto.mart.MartTypeDto; 5 | import com.hongsi.martholidayalarm.core.holiday.Holiday; 6 | import com.hongsi.martholidayalarm.core.mart.MartType; 7 | import org.springframework.data.domain.Sort; 8 | 9 | import java.util.Collection; 10 | import java.util.List; 11 | 12 | public interface MartHolidayRepositoryCustom { 13 | 14 | List findAllOrderBy(Sort sort); 15 | 16 | List findAllById(Collection ids, Sort sort); 17 | 18 | List findAllByMartType(MartType martType, Sort sort); 19 | 20 | List findAllByHoliday(Holiday holiday); 21 | 22 | List findAllMartTypes(); 23 | } 24 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/config/WebMvcConfig.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.config; 2 | 3 | import com.hongsi.martholidayalarm.api.interceptor.ApiLogInterceptor; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.context.annotation.Profile; 7 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 8 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 9 | 10 | @Profile("!test") 11 | @Configuration 12 | @RequiredArgsConstructor 13 | public class WebMvcConfig implements WebMvcConfigurer { 14 | 15 | private final ApiLogInterceptor apiLogInterceptor; 16 | 17 | @Override 18 | public void addInterceptors(InterceptorRegistry registry) { 19 | registry.addInterceptor(apiLogInterceptor).addPathPatterns("/api/**"); 20 | } 21 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/model/PushResult.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.model; 2 | 3 | import lombok.Builder; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | 7 | @Getter 8 | @EqualsAndHashCode 9 | public class PushResult { 10 | 11 | private long totalCount; 12 | private long successCount; 13 | private long failureCount; 14 | 15 | @Builder 16 | private PushResult(long successCount, long failureCount) { 17 | this.totalCount = successCount + failureCount; 18 | this.successCount = successCount; 19 | this.failureCount = failureCount; 20 | } 21 | 22 | public long getSuccessPercentage() { 23 | return Math.round(successCount * 100.0 / totalCount); 24 | } 25 | 26 | public long getFailurePercentage() { 27 | return Math.round(failureCount * 100.0 / totalCount); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/groovy/com/hongsi/martholidayalarm/api/dto/mart/MartOrderTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart 2 | 3 | import spock.lang.Specification 4 | import spock.lang.Unroll 5 | 6 | class MartOrderTest extends Specification { 7 | 8 | def "Should find mart order of same name"() { 9 | given: 10 | def names = MartOrder.values().collect { it.name() } 11 | 12 | when: 13 | def actual = names.collect { MartOrder.of(it) } 14 | 15 | then: 16 | actual == MartOrder.values() as List 17 | } 18 | 19 | @Unroll("#name") 20 | def "Shouldn't find mart order of case-insensitive name"() { 21 | when: 22 | MartOrder.of(name) 23 | 24 | then: 25 | thrown(IllegalArgumentException) 26 | 27 | where: 28 | name | _ 29 | "ID" | _ 30 | "marttype" | _ 31 | "MARTTYPE" | _ 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/main/java/com/hongsi/martholidayalarm/core/holiday/Holidays.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.holiday; 2 | 3 | import lombok.Getter; 4 | import lombok.NoArgsConstructor; 5 | 6 | import java.util.*; 7 | import java.util.stream.Collectors; 8 | 9 | @NoArgsConstructor 10 | @Getter 11 | public class Holidays { 12 | 13 | private static final Holidays EMPTY_HOLIDAYS = new Holidays(); 14 | 15 | private Set holidays = new TreeSet<>(); 16 | 17 | private Holidays(Collection holidays) { 18 | this.holidays.addAll(holidays); 19 | } 20 | 21 | public static Holidays of(Collection holidays) { 22 | if (Objects.isNull(holidays)) { 23 | return EMPTY_HOLIDAYS; 24 | } 25 | return new Holidays(holidays); 26 | } 27 | 28 | public List getUpcomingHolidays() { 29 | return holidays.stream() 30 | .filter(Holiday::isUpcoming) 31 | .collect(Collectors.toList()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/utils/ApplicationContextUtils.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils; 2 | 3 | import org.springframework.beans.BeansException; 4 | import org.springframework.context.ApplicationContext; 5 | import org.springframework.context.ApplicationContextAware; 6 | import org.springframework.lang.Nullable; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | public class ApplicationContextUtils implements ApplicationContextAware { 11 | 12 | private static ApplicationContext applicationContext; 13 | 14 | @Override 15 | public void setApplicationContext(@Nullable ApplicationContext applicationContext) throws BeansException { 16 | ApplicationContextUtils.applicationContext = applicationContext; 17 | } 18 | 19 | public static ApplicationContext getApplicationContext() { 20 | return applicationContext; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/model/PushCounter.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.model; 2 | 3 | import java.util.concurrent.atomic.LongAdder; 4 | 5 | public class PushCounter { 6 | 7 | private LongAdder successCount; 8 | private LongAdder failureCount; 9 | 10 | public PushCounter() { 11 | this.successCount = new LongAdder(); 12 | this.failureCount = new LongAdder(); 13 | } 14 | 15 | public void recordSuccess() { 16 | successCount.add(1); 17 | } 18 | 19 | public void recordFailure() { 20 | failureCount.add(1); 21 | } 22 | 23 | public PushResult getPushResult() { 24 | long success = successCount.sum(); 25 | long failure = failureCount.sum(); 26 | return PushResult.builder() 27 | .successCount(success) 28 | .failureCount(failure) 29 | .build(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-location-converter/src/main/java/com/hongsi/martholidayalarm/client/location/converter/LocationConversion.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.location.converter; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.springframework.util.StringUtils; 7 | 8 | @Data 9 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 10 | public class LocationConversion { 11 | 12 | public static final LocationConversion EMPTY = new LocationConversion(); 13 | 14 | private String address; 15 | private Double latitude; 16 | private Double longitude; 17 | 18 | public LocationConversion(String address, Double latitude, Double longitude) { 19 | this.address = address; 20 | this.latitude = latitude; 21 | this.longitude = longitude; 22 | } 23 | 24 | public String getAddressOrDefault(String defaultAddress) { 25 | return StringUtils.hasText(address) ? address : defaultAddress; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/model/PushUser.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.model; 2 | 3 | import com.google.firebase.database.PropertyName; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.NoArgsConstructor; 6 | import lombok.ToString; 7 | 8 | import java.util.List; 9 | 10 | @NoArgsConstructor 11 | @EqualsAndHashCode(of = "deviceToken") 12 | @ToString 13 | public class PushUser { 14 | 15 | private String deviceToken; 16 | private List favorites; 17 | 18 | public String getDeviceToken() { 19 | return deviceToken; 20 | } 21 | 22 | public void setDeviceToken(String deviceToken) { 23 | this.deviceToken = deviceToken; 24 | } 25 | 26 | @PropertyName("favorites") 27 | public List getFavorites() { 28 | return favorites; 29 | } 30 | 31 | @PropertyName("favorites") 32 | public void setFavorites(List favorites) { 33 | this.favorites = favorites; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/main/java/com/hongsi/martholidayalarm/core/BaseEntity.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core; 2 | 3 | import lombok.Getter; 4 | import org.springframework.data.annotation.CreatedDate; 5 | import org.springframework.data.annotation.LastModifiedDate; 6 | import org.springframework.data.jpa.domain.support.AuditingEntityListener; 7 | 8 | import javax.persistence.Column; 9 | import javax.persistence.EntityListeners; 10 | import javax.persistence.MappedSuperclass; 11 | import java.time.LocalDateTime; 12 | 13 | @MappedSuperclass 14 | @EntityListeners(AuditingEntityListener.class) 15 | @Getter 16 | public class BaseEntity { 17 | 18 | @CreatedDate 19 | @Column(updatable = false) 20 | private LocalDateTime createdDate; 21 | 22 | @LastModifiedDate 23 | private LocalDateTime modifiedDate; 24 | 25 | @Override 26 | public String toString() { 27 | return "BaseEntity{" + 28 | "createdDate=" + createdDate + 29 | ", modifiedDate=" + modifiedDate + 30 | '}'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/test/groovy/com/hongsi/martholidayalarm/push/model/PushResultTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.model 2 | 3 | import spock.lang.Specification 4 | import spock.lang.Unroll 5 | 6 | class PushResultTest extends Specification { 7 | 8 | @Unroll 9 | def "Should calculate success percentage"() { 10 | given: 11 | def result = PushResult.builder() 12 | .successCount(success) 13 | .failureCount(failure) 14 | .build() 15 | 16 | expect: 17 | result.getSuccessPercentage() == successPercentage 18 | result.getFailurePercentage() == failurePercentage 19 | 20 | where: 21 | success | failure || successPercentage | failurePercentage 22 | 3 | 6 || 33 | 67 23 | 2 | 8 || 20 | 80 24 | 0 | 5 || 0 | 100 25 | 0 | 0 || 0 | 0 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-location-converter/src/main/java/com/hongsi/martholidayalarm/client/location/converter/kakao/KakaoLocationSearchResult.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.location.converter.kakao; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import com.hongsi.martholidayalarm.client.location.converter.LocationConversion; 6 | import lombok.Data; 7 | 8 | import java.util.List; 9 | 10 | @Data 11 | @JsonIgnoreProperties(ignoreUnknown = true) 12 | public class KakaoLocationSearchResult { 13 | 14 | @JsonProperty 15 | private List documents; 16 | 17 | public LocationConversion getLocationConversion() { 18 | return documents.stream() 19 | .findFirst() 20 | .map(item -> new LocationConversion(item.getAddress(), item.getLatitude(), item.getLongitude())) 21 | .orElseThrow(IllegalArgumentException::new); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/test/groovy/com/hongsi/martholidayalarm/core/mart/MartTypeTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.mart 2 | 3 | import spock.lang.Specification 4 | import spock.lang.Unroll 5 | 6 | import static com.hongsi.martholidayalarm.core.mart.MartType.* 7 | 8 | class MartTypeTest extends Specification { 9 | 10 | @Unroll 11 | def "한글명으로 코드를 찾을 수 있다 [#name -> #expected]"() { 12 | expect: 13 | of(name) == expected 14 | 15 | where: 16 | name || expected 17 | "이마트" || EMART 18 | "이마트 트레이더스" || EMART_TRADERS 19 | "노브랜드" || NOBRAND 20 | "롯데마트" || LOTTEMART 21 | "홈플러스" || HOMEPLUS 22 | "홈플러스 익스프레스" || HOMEPLUS_EXPRESS 23 | "코스트코" || COSTCO 24 | } 25 | 26 | def "한글명과 일치하는 코드가 없으면 에러가 발생한다"() { 27 | given: 28 | def name = "리마트" 29 | 30 | when: 31 | of(name) 32 | 33 | then: 34 | thrown(IllegalArgumentException) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/costco/CostcoCrawler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.costco; 2 | 3 | import com.fasterxml.jackson.core.type.TypeReference; 4 | import com.fasterxml.jackson.databind.JsonNode; 5 | import com.hongsi.martholidayalarm.crawler.MartCrawler; 6 | import com.hongsi.martholidayalarm.crawler.model.MartParser; 7 | import com.hongsi.martholidayalarm.crawler.utils.JsonParser; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.util.List; 11 | 12 | @Component 13 | public class CostcoCrawler implements MartCrawler { 14 | 15 | private static final String CRAWL_URL = "https://www.costco.co.kr/store-finder/search?q=Korea%2C+Republic+of&page=0"; 16 | private static final String JSON_DATA_KEY = "data"; 17 | 18 | @Override 19 | public List crawl() { 20 | JsonNode martInfo = JsonParser.request(CRAWL_URL); 21 | return JsonParser.convert(martInfo.get(JSON_DATA_KEY), new TypeReference>() { 22 | }); 23 | } 24 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/config/PushConfig.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.scheduling.annotation.EnableAsync; 6 | import org.springframework.scheduling.annotation.EnableScheduling; 7 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 8 | 9 | import java.util.concurrent.ThreadPoolExecutor; 10 | 11 | @Configuration 12 | @EnableScheduling 13 | @EnableAsync 14 | public class PushConfig { 15 | 16 | @Bean 17 | public ThreadPoolTaskExecutor pushThreadPool() { 18 | ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor(); 19 | taskExecutor.setCorePoolSize(3); 20 | taskExecutor.setMaxPoolSize(3); 21 | taskExecutor.setThreadNamePrefix("Push-"); 22 | taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); 23 | taskExecutor.initialize(); 24 | return taskExecutor; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/mart/MartSortParser.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart; 2 | 3 | import org.springframework.data.domain.Sort; 4 | import org.springframework.data.domain.Sort.Order; 5 | 6 | import java.util.List; 7 | import java.util.Optional; 8 | import java.util.stream.Collectors; 9 | 10 | public class MartSortParser { 11 | 12 | public static Sort parse(List orders, Order... defaultValue) { 13 | return orders != null ? parseOrders(orders) : getDefaultSort(defaultValue); 14 | } 15 | 16 | private static Sort parseOrders(List orders) { 17 | return Sort.by( 18 | orders.stream() 19 | .map(MartOrderParser::parse) 20 | .filter(Optional::isPresent) 21 | .map(Optional::get) 22 | .collect(Collectors.toList()) 23 | ); 24 | } 25 | 26 | private static Sort getDefaultSort(Order[] defaultValue) { 27 | return defaultValue != null ? Sort.by(defaultValue) : Sort.unsorted(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/holiday/LocalDateRange.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday; 2 | 3 | import java.time.LocalDate; 4 | 5 | public class LocalDateRange { 6 | 7 | private final LocalDate start; 8 | private final LocalDate end; 9 | 10 | private LocalDateRange(LocalDate start, LocalDate end) { 11 | this.start = start; 12 | this.end = end; 13 | } 14 | 15 | public static LocalDateRange withDays(LocalDate start, int days) { 16 | return of(start, start.plusDays(days)); 17 | } 18 | 19 | public static LocalDateRange of(LocalDate start, LocalDate end) { 20 | if (start == null || end == null) { 21 | throw new IllegalArgumentException("Must need both start and end"); 22 | } 23 | if (end.isBefore(start)) { 24 | throw new IllegalArgumentException("Start must be before end"); 25 | } 26 | return new LocalDateRange(start, end); 27 | } 28 | 29 | public boolean isBetween(LocalDate date) { 30 | return !date.isBefore(start) && !date.isAfter(end); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.asciidoctor.convert") version "1.5.9.2" 3 | } 4 | 5 | dependencies { 6 | compile project(":mart-holiday-alarm-core") 7 | compile project(":mart-holiday-alarm-crawler") 8 | compile project(":mart-holiday-alarm-push") 9 | 10 | implementation "org.springframework.boot:spring-boot-starter-web" 11 | implementation "org.springframework.boot:spring-boot-starter-actuator" 12 | implementation "org.springframework.boot:spring-boot-starter-aop" 13 | 14 | implementation "org.jsoup:jsoup:1.10.2" 15 | implementation "com.ibm.icu:icu4j:63.1" 16 | 17 | testImplementation "org.springframework.restdocs:spring-restdocs-mockmvc" 18 | asciidoctor "org.springframework.restdocs:spring-restdocs-asciidoctor" 19 | } 20 | 21 | // Rest Dos Configuration 22 | ext { 23 | snippetsDir = file('build/generated-snippets') 24 | } 25 | 26 | test { 27 | outputs.dir snippetsDir 28 | } 29 | 30 | asciidoctor { 31 | inputs.dir snippetsDir 32 | dependsOn test 33 | } 34 | 35 | bootJar { 36 | dependsOn asciidoctor 37 | from("${asciidoctor.outputDir}/html5") { 38 | into 'static/docs/' 39 | } 40 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-slack/src/main/java/com/hongsi/martholidayalarm/client/slack/SlackNotifier.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.slack; 2 | 3 | import com.hongsi.martholidayalarm.client.slack.model.SlackMessage; 4 | import lombok.RequiredArgsConstructor; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.web.client.RestTemplate; 9 | 10 | @Component 11 | @RequiredArgsConstructor 12 | @Slf4j 13 | public class SlackNotifier { 14 | 15 | private final RestTemplate restTemplate; 16 | 17 | public SlackNotifier() { 18 | this.restTemplate = new RestTemplate(); 19 | } 20 | 21 | @Value("${slack.webhook.url:''}") 22 | private String url; 23 | 24 | public boolean notify(SlackMessage slackMessage) { 25 | try { 26 | restTemplate.postForEntity(url, slackMessage, String.class); 27 | return true; 28 | } catch (Exception e) { 29 | log.error("failed to notify slack message", e); 30 | return false; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/ApiApplication.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.builder.SpringApplicationBuilder; 6 | import org.springframework.context.ConfigurableApplicationContext; 7 | import org.springframework.context.annotation.EnableAspectJAutoProxy; 8 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing; 9 | 10 | @Slf4j 11 | @SpringBootApplication 12 | @EnableAspectJAutoProxy 13 | @EnableJpaAuditing 14 | public class ApiApplication { 15 | 16 | public static void main(String[] args) { 17 | ConfigurableApplicationContext context = new SpringApplicationBuilder(ApiApplication.class) 18 | .run(args); 19 | printPropertySources(context); 20 | } 21 | 22 | private static void printPropertySources(ConfigurableApplicationContext context) { 23 | context.getEnvironment() 24 | .getPropertySources() 25 | .forEach(propertySource -> log.info("{} : {}", propertySource.getName(), propertySource.getSource())); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/exception/InvalidMartTypeException.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.exception; 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType; 4 | 5 | import java.util.Arrays; 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | import java.util.stream.Collectors; 9 | 10 | public class InvalidMartTypeException extends RuntimeException { 11 | 12 | private static final Map MART_TYPES = new HashMap<>(1); 13 | 14 | static { 15 | String joinedMartTypeNames = Arrays.stream(MartType.values()) 16 | .map(MartType::name) 17 | .collect(Collectors.joining(", ")); 18 | MART_TYPES.put("martTypes", joinedMartTypeNames); 19 | } 20 | 21 | private final String requestValue; 22 | 23 | public InvalidMartTypeException(String requestValue) { 24 | this.requestValue = requestValue; 25 | } 26 | 27 | @Override 28 | public String getMessage() { 29 | return String.format("잘못된 마트입니다. [value : %s]", requestValue); 30 | } 31 | 32 | public Map getPossibleMartTypes() { 33 | return MART_TYPES; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/MartCrawlerAsyncService.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler; 2 | 3 | import com.hongsi.martholidayalarm.crawler.model.CrawledMart; 4 | import com.hongsi.martholidayalarm.crawler.utils.ApplicationContextUtils; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.scheduling.annotation.Async; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.util.List; 11 | import java.util.stream.Collectors; 12 | 13 | @Component 14 | @RequiredArgsConstructor 15 | @Slf4j 16 | public class MartCrawlerAsyncService { 17 | 18 | private final MartCrawlerService martCrawlerService; 19 | 20 | @Async("crawlerThreadPool") 21 | public void crawl(MartCrawlerType crawlerMartType) { 22 | MartCrawler martCrawler = ApplicationContextUtils.getApplicationContext().getBean(crawlerMartType.getMartCrawler()); 23 | List crawledMarts = martCrawler.crawl().stream() 24 | .map(CrawledMart::parse) 25 | .collect(Collectors.toList()); 26 | martCrawlerService.saveCrawledMarts(crawledMarts); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/mart/MartOrderParser.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart; 2 | 3 | import org.springframework.data.domain.Sort.Order; 4 | 5 | import java.util.Optional; 6 | import java.util.regex.Matcher; 7 | import java.util.regex.Pattern; 8 | 9 | public class MartOrderParser { 10 | 11 | private static final Pattern ORDER_PATTERN = Pattern.compile("([a-zA-Z]+):?(asc|desc)?"); 12 | private static final int PROPERTY_GROUP = 1; 13 | private static final int DIRECTION_GROUP = 2; 14 | 15 | public static Optional parse(String value) { 16 | try { 17 | Matcher matcher = ORDER_PATTERN.matcher(value); 18 | if (!matcher.matches()) { 19 | throw new IllegalArgumentException("Not found order value"); 20 | } 21 | 22 | MartOrder martOrder = MartOrder.of(matcher.group(PROPERTY_GROUP)); 23 | String direction = matcher.group(DIRECTION_GROUP); 24 | return Optional.of(direction != null ? martOrder.by(direction) : martOrder.asc()); 25 | } catch (Exception e) { 26 | return Optional.empty(); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/utils/HtmlParser.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils; 2 | 3 | import com.hongsi.martholidayalarm.crawler.exception.PageNotFoundException; 4 | import org.jsoup.Connection; 5 | import org.jsoup.Connection.Method; 6 | import org.jsoup.Jsoup; 7 | import org.jsoup.nodes.Document; 8 | 9 | import java.io.IOException; 10 | import java.util.Map; 11 | import java.util.Objects; 12 | 13 | public class HtmlParser { 14 | 15 | public static Document get(String url) { 16 | return parse(url, Method.GET, null); 17 | } 18 | 19 | public static Document post(String url, Map params) { 20 | return parse(url, Method.POST, params); 21 | } 22 | 23 | public static Document parse(String url, Method method, Map params) { 24 | try { 25 | Connection connection = Jsoup.connect(url).method(method); 26 | if (Objects.nonNull(params)) { 27 | connection.data(params); 28 | } 29 | return connection.execute().parse(); 30 | } catch (IOException e) { 31 | throw new PageNotFoundException(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-firebase/src/main/java/com/hongsi/martholidayalarm/clients/firebase/message/FirebaseMessageSender.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.clients.firebase.message; 2 | 3 | import com.google.firebase.messaging.FirebaseMessaging; 4 | import com.google.firebase.messaging.FirebaseMessagingException; 5 | import com.google.firebase.messaging.Message; 6 | import com.hongsi.martholidayalarm.clients.firebase.exception.PushException; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | @Slf4j 12 | public class FirebaseMessageSender { 13 | 14 | public String send(Message message) throws PushException { 15 | String messageId = null; 16 | try { 17 | messageId = FirebaseMessaging.getInstance().send(message); 18 | } catch (FirebaseMessagingException e) { 19 | log.error("failed to send message. errorCode : {}, message : {}", e.getErrorCode(), e.getMessage()); 20 | throw new PushException(e.getErrorCode(), e.getMessage()); 21 | } catch (Exception e) { 22 | log.error("failed to send message.", e); 23 | } 24 | return messageId; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/java/com/hongsi/martholidayalarm/api/docs/CommonResponseFieldsSnippet.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.docs; 2 | 3 | import org.springframework.http.MediaType; 4 | import org.springframework.restdocs.operation.Operation; 5 | import org.springframework.restdocs.payload.AbstractFieldsSnippet; 6 | import org.springframework.restdocs.payload.FieldDescriptor; 7 | import org.springframework.restdocs.payload.PayloadSubsectionExtractor; 8 | 9 | import java.util.List; 10 | import java.util.Map; 11 | 12 | public class CommonResponseFieldsSnippet extends AbstractFieldsSnippet { 13 | 14 | public CommonResponseFieldsSnippet(String type, PayloadSubsectionExtractor subsectionExtractor, 15 | List descriptors, Map attributes, boolean ignoreUndocumentedFields) { 16 | super(type, descriptors, attributes, ignoreUndocumentedFields, 17 | subsectionExtractor); 18 | } 19 | 20 | @Override 21 | protected MediaType getContentType(Operation operation) { 22 | return operation.getResponse().getHeaders().getContentType(); 23 | } 24 | 25 | @Override 26 | protected byte[] getContent(Operation operation) { 27 | return operation.getResponse().getContent(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/test/groovy/com/hongsi/martholidayalarm/core/location/LocationTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.location 2 | 3 | import com.hongsi.martholidayalarm.core.exception.LocationOutOfRangeException 4 | import spock.lang.Specification 5 | 6 | import static com.hongsi.martholidayalarm.core.location.Location.Range.Latitude 7 | import static com.hongsi.martholidayalarm.core.location.Location.Range.Longitude 8 | 9 | class LocationTest extends Specification { 10 | 11 | def "위도, 경도는 필수값이다"() { 12 | when: 13 | Location.of(latitude, longitude) 14 | 15 | then: 16 | thrown(IllegalArgumentException) 17 | 18 | where: 19 | latitude | longitude 20 | null | 0 21 | 0 | null 22 | } 23 | 24 | def "위도, 경도는 유효 범위에 포함돼야 한다"() { 25 | when: 26 | Location.of(latitude, longitude) 27 | 28 | then: 29 | thrown(LocationOutOfRangeException) 30 | 31 | where: 32 | latitude | longitude 33 | Latitude.min - 1 | Longitude.min 34 | Latitude.max + 1 | Longitude.max 35 | Latitude.min | Longitude.min - 1 36 | Latitude.max | Longitude.max + 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/repository/PushUserRepository.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.repository; 2 | 3 | import com.google.firebase.database.DatabaseReference; 4 | import com.google.firebase.database.ValueEventListener; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import static com.hongsi.martholidayalarm.clients.firebase.database.domain.FirebaseDatabaseWrapper.root; 9 | 10 | @Repository 11 | @Slf4j 12 | public class PushUserRepository { 13 | 14 | private static final String USERS = "users"; 15 | 16 | public void findAll(ValueEventListener callback) { 17 | users().addListenerForSingleValueEvent(callback); 18 | } 19 | 20 | public void delete(String deviceToken) { 21 | users().child(deviceToken).removeValue((error, ref) -> { 22 | if (error == null) { 23 | log.info("successfully removed device token: {}", ref.getKey()); 24 | } else { 25 | log.error("failed to remove device token: {}, error: {}", ref.getKey(), error.getMessage()); 26 | } 27 | }); 28 | } 29 | 30 | private DatabaseReference users() { 31 | return root().child(USERS); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 마트휴일알리미 ![build](https://github.com/hongsii/mart-holiday-alarm/workflows/build/badge.svg?branch=master) 2 | 3 | * 마트쉬는날 - 휴무일 알리미 ([App Store](https://itunes.apple.com/kr/app/%EB%A7%88%ED%8A%B8%EC%89%AC%EB%8A%94%EB%82%A0-%ED%9C%B4%EB%AC%B4%EC%9D%BC-%EC%95%8C%EB%A6%AC%EB%AF%B8/id1438702208?mt=8)) 4 | 5 | ------------------------- 6 | 7 | ## 개발 환경 8 | 9 | * 언어 : Java 8 (openjdk 8) 10 | * 프레임워크 : Spring Boot 2.1.1.RELEASE, JPA, Querydsl 4.2.1 11 | * 데이터베이스 : AWS RDS (MariaDB), Firebase Realtime DB 12 | * 빌드 환경 : JUnit(AssertJ), Lombok, Gradle, Github Actions, AWS CodeDeploy 13 | * 라이브러리 : Spring Rest Docs, Jsoup 1.10.2, Firebase Messaging 14 | 15 | ------------------------- 16 | 17 | ## 소개 18 | 19 | 마트 정보 및 휴일을 조회할 수 있는 서비스 20 | 21 | * 조회 가능한 마트 22 | * 이마트 23 | * 이마트 트레이더스 24 | * 노브랜드 25 | * 롯데마트 26 | * 홈플러스 27 | * 홈플러스 익스프레스 28 | * 코스트코 29 | 30 | ------------------------- 31 | 32 | ## 사용법 33 | 34 | ### 마트쉬는날 - 휴무일 알리미 (iOS앱) 35 | 36 | * App Store에서 '마트쉬는날' 검색 37 | * [App Store](https://itunes.apple.com/kr/app/%EB%A7%88%ED%8A%B8%EC%89%AC%EB%8A%94%EB%82%A0-%ED%9C%B4%EB%AC%B4%EC%9D%BC-%EC%95%8C%EB%A6%AC%EB%AF%B8/id1438702208?mt=8) 링크를 통해 다운로드 38 | 39 | #### 기능 40 | 41 | * 마트를 즐겨찾기해 간편하게 휴일 조회 42 | * 즐겨찾기된 마트를 위젯으로 조회 가능 43 | * 즐겨찾기한 마트의 휴일 전날 오전에 푸시 알림 44 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/controller/validator/ValidLocationRangeValidator.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller.validator; 2 | 3 | import com.hongsi.martholidayalarm.core.exception.LocationOutOfRangeException; 4 | import com.hongsi.martholidayalarm.core.location.Location; 5 | 6 | import javax.validation.ConstraintValidator; 7 | import javax.validation.ConstraintValidatorContext; 8 | import java.lang.annotation.Annotation; 9 | 10 | public abstract class ValidLocationRangeValidator implements ConstraintValidator { 11 | 12 | private final Location.Range range; 13 | private final String message; 14 | 15 | public ValidLocationRangeValidator(Location.Range range) { 16 | this.range = range; 17 | this.message = LocationOutOfRangeException.getMessageWithTemplate(range); 18 | } 19 | 20 | @Override 21 | public boolean isValid(Double value, ConstraintValidatorContext context) { 22 | boolean valid = value != null && range.isValid(value); 23 | if (!valid) { 24 | context.disableDefaultConstraintViolation(); 25 | context.buildConstraintViolationWithTemplate(message).addConstraintViolation(); 26 | } 27 | return valid; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/mart/MartOrder.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart; 2 | 3 | import java.util.Arrays; 4 | import java.util.stream.Collectors; 5 | 6 | import static org.springframework.data.domain.Sort.Direction; 7 | import static org.springframework.data.domain.Sort.Order; 8 | 9 | public enum MartOrder { 10 | 11 | id, martType, branchName, region; 12 | 13 | private static final String COLUMN_ERROR_MESSAGE = String.format("일치하는 정렬 컬럼이 없습니다.\n- 가능한 정렬 : [%s]", 14 | Arrays.stream(values()) 15 | .map(MartOrder::name) 16 | .collect(Collectors.joining(", ")) 17 | ); 18 | 19 | public static MartOrder of(String name) { 20 | return Arrays.stream(values()) 21 | .filter(property -> property.name().equals(name)) 22 | .findFirst() 23 | .orElseThrow(() -> new IllegalArgumentException(COLUMN_ERROR_MESSAGE)); 24 | } 25 | 26 | public Order asc() { 27 | return Order.asc(name()); 28 | } 29 | 30 | public Order desc() { 31 | return Order.desc(name()); 32 | } 33 | 34 | public Order by(String direction) { 35 | return new Order(Direction.fromString(direction), name()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/MartParser.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model; 2 | 3 | import com.hongsi.martholidayalarm.core.holiday.Holiday; 4 | import com.hongsi.martholidayalarm.core.location.Location; 5 | import com.hongsi.martholidayalarm.core.mart.MartType; 6 | import com.hongsi.martholidayalarm.crawler.model.holiday.RegularHolidayGenerator; 7 | 8 | import java.time.LocalDate; 9 | import java.util.Collections; 10 | import java.util.List; 11 | 12 | public interface MartParser { 13 | 14 | MartType getMartType(); 15 | 16 | String getRealId(); 17 | 18 | String getBranchName(); 19 | 20 | String getRegion(); 21 | 22 | String getPhoneNumber(); 23 | 24 | String getAddress(); 25 | 26 | String getOpeningHours(); 27 | 28 | String getUrl(); 29 | 30 | Location getLocation(); 31 | 32 | String getHolidayText(); 33 | 34 | List getHolidays(); 35 | 36 | default List generateRegularHolidays(String holidayText) { 37 | try { 38 | RegularHolidayGenerator generator = RegularHolidayGenerator.from(holidayText); 39 | return generator.generate(LocalDate.now()); 40 | } catch (Exception e) { 41 | return Collections.emptyList(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/config/SchedulerConfig.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.scheduling.TaskScheduler; 6 | import org.springframework.scheduling.annotation.EnableAsync; 7 | import org.springframework.scheduling.annotation.EnableScheduling; 8 | import org.springframework.scheduling.concurrent.ConcurrentTaskScheduler; 9 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 10 | 11 | import java.util.concurrent.ThreadPoolExecutor; 12 | 13 | @Configuration 14 | @EnableScheduling 15 | @EnableAsync 16 | public class SchedulerConfig { 17 | 18 | @Bean 19 | public TaskScheduler defaultScheduler() { 20 | return new ConcurrentTaskScheduler(); 21 | } 22 | 23 | @Bean 24 | public ThreadPoolTaskExecutor crawlerThreadPool() { 25 | ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor(); 26 | taskExecutor.setCorePoolSize(0); 27 | taskExecutor.setMaxPoolSize(3); 28 | taskExecutor.setQueueCapacity(3); 29 | taskExecutor.setThreadNamePrefix("MartCrawler-"); 30 | taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); 31 | taskExecutor.initialize(); 32 | return taskExecutor; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/ApiException.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonInclude.Include; 5 | import lombok.Getter; 6 | import lombok.ToString; 7 | 8 | @Getter 9 | @ToString 10 | public class ApiException { 11 | 12 | private final ApiResponseCode code; 13 | private final String message; 14 | @JsonInclude(Include.NON_NULL) 15 | private final T info; 16 | 17 | private ApiException(ApiResponseCode code, String message, T info) { 18 | this.code = code; 19 | this.message = message != null ? message : code.getMessage(); 20 | this.info = info; 21 | } 22 | 23 | public static ApiException of(ApiResponseCode code) { 24 | return new ApiException<>(code, null, null); 25 | } 26 | 27 | public static ApiException of(ApiResponseCode code, String message) { 28 | return new ApiException<>(code, message, null); 29 | } 30 | 31 | public static ApiException withInfo(ApiResponseCode code, T info) { 32 | return new ApiException<>(code, null, info); 33 | } 34 | 35 | public static ApiException withInfo(ApiResponseCode code, String message, T info) { 36 | return new ApiException<>(code, message, info); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/groovy/com/hongsi/martholidayalarm/api/dto/mart/MartOrderParserTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart 2 | 3 | import spock.lang.Specification 4 | import spock.lang.Unroll 5 | 6 | import static org.springframework.data.domain.Sort.Order 7 | 8 | class MartOrderParserTest extends Specification { 9 | 10 | @Unroll("#order => #expected") 11 | def "Should parse order from valid format"() { 12 | when: 13 | def actual = MartOrderParser.parse(order) 14 | 15 | then: 16 | actual == Optional.of(expected) 17 | 18 | where: 19 | order || expected 20 | "martType" || Order.asc("martType") 21 | "martType:" || Order.asc("martType") 22 | "martType:asc" || Order.asc("martType") 23 | "martType:desc" || Order.desc("martType") 24 | } 25 | 26 | @Unroll("#order => nothing") 27 | def "Shouldn't parse order from invalid format"() { 28 | when: 29 | def actual = MartOrderParser.parse(order) 30 | 31 | then: 32 | actual == Optional.empty() 33 | 34 | where: 35 | order | _ 36 | null | _ 37 | "" | _ 38 | ":asc" | _ 39 | "foo" | _ 40 | "foo:bar" | _ 41 | "foo:asc" | _ 42 | "martType|asc" | _ 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/repository/PushMartRepositoryImpl.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.repository; 2 | 3 | import com.hongsi.martholidayalarm.core.BaseQuerydslRepositorySupport; 4 | import com.hongsi.martholidayalarm.core.mart.Mart; 5 | import com.hongsi.martholidayalarm.push.model.PushMart; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.time.LocalDate; 9 | import java.util.List; 10 | import java.util.stream.Collectors; 11 | 12 | import static com.hongsi.martholidayalarm.core.holiday.QHoliday.holiday; 13 | import static com.hongsi.martholidayalarm.core.mart.QMart.mart; 14 | 15 | @Repository 16 | public class PushMartRepositoryImpl extends BaseQuerydslRepositorySupport implements PushMartRepositoryCustom { 17 | 18 | public PushMartRepositoryImpl() { 19 | super(Mart.class); 20 | } 21 | 22 | @Override 23 | public List findAllByIdInAndHolidayDate(List ids, LocalDate holidayDate) { 24 | return select(mart) 25 | .from(mart) 26 | .distinct() 27 | .innerJoin(mart.holidays, holiday).fetchJoin() 28 | .where(mart.id.in(ids).and(holiday.date.eq(holidayDate))) 29 | .fetch() 30 | .stream() 31 | .map(PushMart::from) 32 | .collect(Collectors.toList()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/utils/MatchSpliteratorTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils 2 | 3 | import spock.lang.Specification 4 | 5 | import java.util.regex.Pattern 6 | import java.util.stream.Collectors 7 | 8 | class MatchSpliteratorTest extends Specification { 9 | 10 | def "Should collect all matched text"() { 11 | given: 12 | def pattern = Pattern.compile("test(\\d+)") 13 | def targetTexts = ["test1", "test2", "test3"] 14 | def nonTargetTexts = ["tes1t", "test"] 15 | 16 | when: 17 | def matchSpliterator = MatchSpliterator.from(pattern, (targetTexts + nonTargetTexts).join(" ")) 18 | def words = matchSpliterator.stream().collect(Collectors.toList()) 19 | 20 | then: 21 | words == targetTexts 22 | } 23 | 24 | def "Should collect specified matched group"() { 25 | given: 26 | def pattern = Pattern.compile("test(\\d+)") 27 | def targetTexts = ["test1", "test2", "test3"] 28 | def nonTargetTexts = ["tes1t", "test"] 29 | def groupIndex = 1 30 | 31 | when: 32 | def matchSpliterator = MatchSpliterator.from(pattern, (targetTexts + nonTargetTexts).join(" "), groupIndex) 33 | def words = matchSpliterator.stream().collect(Collectors.toList()) 34 | 35 | then: 36 | words == ["1", "2", "3"] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/emart/NobrandCrawler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.emart; 2 | 3 | import com.hongsi.martholidayalarm.crawler.model.MartParser; 4 | import com.hongsi.martholidayalarm.crawler.utils.HtmlParser; 5 | import org.jsoup.nodes.Document; 6 | import org.springframework.stereotype.Component; 7 | 8 | import java.util.List; 9 | import java.util.stream.Collectors; 10 | 11 | @Component 12 | public class NobrandCrawler extends EmartCommonCrawler { 13 | 14 | private static final String DETAIL_VIEW_URL_FORMAT = "https://store.emart.com/branch/view.do?id=%s"; 15 | private static final String CSS_SELECTOR_PHONE_NUMBER = ".intro-wrap > ul > li:nth-child(3) > p"; 16 | 17 | @Override 18 | public List crawl() { 19 | List marts = super.crawl(); 20 | return marts.stream() 21 | .parallel() 22 | .peek(this::setPhoneNumber) 23 | .collect(Collectors.toList()); 24 | } 25 | 26 | @Override 27 | protected SearchType getSearchType() { 28 | return SearchType.NOBRAND; 29 | } 30 | 31 | private void setPhoneNumber(MartParser martParser) { 32 | Document detail = HtmlParser.get(String.format(DETAIL_VIEW_URL_FORMAT, martParser.getRealId())); 33 | ((EmartParser) martParser).setPhoneNumber(detail.select(CSS_SELECTOR_PHONE_NUMBER).text()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/groovy/com/hongsi/martholidayalarm/api/dto/mart/MartSortParserTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart 2 | 3 | import org.springframework.data.domain.Sort 4 | import spock.lang.Specification 5 | import spock.lang.Unroll 6 | 7 | import static com.hongsi.martholidayalarm.api.dto.mart.MartOrder.* 8 | 9 | class MartSortParserTest extends Specification { 10 | 11 | @Unroll("#orders => #expected") 12 | def "Should parse sort from valid format"() { 13 | when: 14 | def actual = MartSortParser.parse(orders, id.asc()) 15 | 16 | then: 17 | actual == Sort.by(expected) 18 | 19 | where: 20 | orders || expected 21 | ["martType:asc", "invalid"] || [martType.asc()] 22 | ["invalid:asc", "branchName:desc"] || [branchName.desc()] 23 | ["martType:asc", "branchName:desc"] || [martType.asc(), branchName.desc()] 24 | } 25 | 26 | @Unroll("#orders | #defaultValue => #expected") 27 | def "Should parse sort with empty parameter"() { 28 | when: 29 | def actual = MartSortParser.parse(orders, defaultValue) 30 | 31 | then: 32 | actual == expected 33 | 34 | where: 35 | orders | defaultValue || expected 36 | null | id.asc() || Sort.by(id.asc()) 37 | null | null || Sort.unsorted() 38 | [] | null || Sort.unsorted() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/MartPushScheduler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push; 2 | 3 | import com.hongsi.martholidayalarm.push.service.MartPusher; 4 | import lombok.RequiredArgsConstructor; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.context.annotation.Profile; 7 | import org.springframework.scheduling.annotation.Scheduled; 8 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 9 | import org.springframework.stereotype.Component; 10 | 11 | import javax.annotation.PostConstruct; 12 | import java.time.Duration; 13 | 14 | @Component 15 | @RequiredArgsConstructor 16 | @Slf4j 17 | @Profile({"prod1", "prod2"}) 18 | public class MartPushScheduler { 19 | 20 | private final MartPusher martPusher; 21 | private final ThreadPoolTaskExecutor pushThreadPool; 22 | 23 | @PostConstruct 24 | public void init() { 25 | log.info("started mart push scheduler"); 26 | } 27 | 28 | @Scheduled(cron = "${schedule.cron.push:0 0 11 ? * *}") 29 | public void start() throws Exception { 30 | martPusher.pushToUsers(); 31 | await(); 32 | } 33 | 34 | private void await() throws Exception { 35 | Duration threadWaiting = Duration.ofSeconds(5); 36 | while (true) { 37 | Thread.sleep(threadWaiting.toMillis()); 38 | int activeCount = pushThreadPool.getActiveCount(); 39 | if (activeCount == 0) break; 40 | 41 | log.info("wait to finish for push. active count: {}", activeCount); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/model/PushMart.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.model; 2 | 3 | import com.hongsi.martholidayalarm.core.holiday.Holiday; 4 | import com.hongsi.martholidayalarm.core.mart.Mart; 5 | import com.hongsi.martholidayalarm.core.mart.MartType; 6 | import lombok.Builder; 7 | import lombok.EqualsAndHashCode; 8 | import lombok.Getter; 9 | import lombok.ToString; 10 | 11 | import java.time.format.DateTimeFormatter; 12 | import java.util.Locale; 13 | 14 | @Getter 15 | @EqualsAndHashCode(of = "id") 16 | @ToString 17 | public class PushMart { 18 | 19 | private static final DateTimeFormatter PUSH_HOLIDAY_FORMATTER = DateTimeFormatter 20 | .ofPattern("d일").withLocale(Locale.KOREAN); 21 | 22 | private final Long id; 23 | private final MartType martType; 24 | private final String branchName; 25 | private final Holiday holiday; 26 | 27 | @Builder 28 | public PushMart(Long id, MartType martType, String branchName, Holiday holiday) { 29 | this.id = id; 30 | this.martType = martType; 31 | this.branchName = branchName; 32 | this.holiday = holiday; 33 | } 34 | 35 | public static PushMart from(Mart mart) { 36 | return new PushMart(mart.getId(), mart.getMartType(), mart.getBranchName(), mart.getUpcomingHoliday()); 37 | } 38 | 39 | public String getMartType() { 40 | return martType.getName(); 41 | } 42 | 43 | public String getFormattedHoliday() { 44 | return holiday.getFormattedHoliday(PUSH_HOLIDAY_FORMATTER); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/holiday/KoreanWeekTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday 2 | 3 | import com.hongsi.martholidayalarm.crawler.model.holiday.KoreanWeek 4 | import spock.lang.Specification 5 | 6 | class KoreanWeekTest extends Specification { 7 | 8 | def "Should parse week from korean or numeric character"() { 9 | when: 10 | def actual = text.collect { KoreanWeek.of(it) } 11 | 12 | then: 13 | actual.every { it == expected } 14 | 15 | where: 16 | text || expected 17 | ["첫", "1"] || KoreanWeek.FIRST 18 | ["둘", "두", "2"] || KoreanWeek.SECOND 19 | ["셋", "3"] || KoreanWeek.THIRD 20 | ["넷", "4"] || KoreanWeek.FOURTH 21 | ["다섯", "5"] || KoreanWeek.FIFTH 22 | } 23 | 24 | def "Should parse to list from korean or numeric character"() { 25 | when: 26 | def actual = KoreanWeek.parseToCollection(text) 27 | 28 | then: 29 | actual == expected 30 | 31 | where: 32 | text || expected 33 | "첫" || [KoreanWeek.FIRST] 34 | "첫, 둘" || [KoreanWeek.FIRST, KoreanWeek.SECOND] 35 | "첫,둘,셋" || [KoreanWeek.FIRST, KoreanWeek.SECOND, KoreanWeek.THIRD] 36 | } 37 | 38 | def "Shouldn't parse week from invalid text"() { 39 | when: 40 | KoreanWeek.of("일") 41 | 42 | then: 43 | thrown(IllegalArgumentException) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/holiday/RegularHolidayParserTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday 2 | 3 | import com.hongsi.martholidayalarm.crawler.model.holiday.RegularHoliday 4 | import com.hongsi.martholidayalarm.crawler.model.holiday.RegularHolidayParser 5 | import spock.lang.Specification 6 | 7 | import java.time.LocalDate 8 | 9 | import static com.hongsi.martholidayalarm.crawler.model.holiday.KoreanDayOfWeek.SATURDAY 10 | import static com.hongsi.martholidayalarm.crawler.model.holiday.KoreanDayOfWeek.WEDNESDAY 11 | import static com.hongsi.martholidayalarm.crawler.model.holiday.KoreanWeek.FOURTH 12 | import static com.hongsi.martholidayalarm.crawler.model.holiday.KoreanWeek.SECOND 13 | 14 | class RegularHolidayParserTest extends Specification { 15 | 16 | def "Should parse date of regular holiday"() { 17 | given: 18 | def regularHolidayParser = RegularHolidayParser.from(now) 19 | def regularHoliday = RegularHoliday.of(koreanWeek, koreanDayOfWeek) 20 | 21 | when: 22 | def actual = regularHolidayParser.parse(regularHoliday) 23 | 24 | then: 25 | actual == expected 26 | 27 | where: 28 | now | koreanWeek | koreanDayOfWeek || expected 29 | LocalDate.of(2019, 10, 3) | SECOND | WEDNESDAY || [LocalDate.of(2019, 10, 9)] 30 | LocalDate.of(2019, 10, 3) | FOURTH | SATURDAY || [LocalDate.of(2019, 10, 26)] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/main/java/com/hongsi/martholidayalarm/core/holiday/Holiday.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.holiday; 2 | 3 | import lombok.EqualsAndHashCode; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | import lombok.ToString; 7 | 8 | import javax.persistence.Column; 9 | import javax.persistence.Embeddable; 10 | import java.time.LocalDate; 11 | import java.time.format.DateTimeFormatter; 12 | import java.util.Locale; 13 | 14 | @Embeddable 15 | @NoArgsConstructor 16 | @Getter 17 | @EqualsAndHashCode 18 | @ToString 19 | public class Holiday implements Comparable { 20 | 21 | private static final DateTimeFormatter VIEW_DATE_FORMATTER = DateTimeFormatter 22 | .ofPattern("yyyy-MM-dd (EE)").withLocale(Locale.KOREAN); 23 | 24 | @Column 25 | private LocalDate date; 26 | 27 | private Holiday(LocalDate date) { 28 | this.date = date; 29 | } 30 | 31 | public static Holiday of(int year, int month, int dayOfMonth) { 32 | return of(LocalDate.of(year, month, dayOfMonth)); 33 | } 34 | 35 | public static Holiday of(LocalDate date) { 36 | return new Holiday(date); 37 | } 38 | 39 | public boolean isUpcoming() { 40 | return !date.isBefore(LocalDate.now()); 41 | } 42 | 43 | public String getFormattedHoliday() { 44 | return getFormattedHoliday(VIEW_DATE_FORMATTER); 45 | } 46 | 47 | public String getFormattedHoliday(DateTimeFormatter formatter) { 48 | return date.format(formatter); 49 | } 50 | 51 | @Override 52 | public int compareTo(Holiday other) { 53 | return date.compareTo(other.date); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/MartCrawlerScheduler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.scheduling.annotation.Scheduled; 6 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 7 | import org.springframework.stereotype.Component; 8 | 9 | import javax.annotation.PostConstruct; 10 | import java.time.Duration; 11 | import java.util.Arrays; 12 | 13 | @Component 14 | @RequiredArgsConstructor 15 | @Slf4j 16 | public class MartCrawlerScheduler { 17 | 18 | private final MartCrawlerAsyncService martCrawlerAsyncService; 19 | private final ThreadPoolTaskExecutor crawlerThreadPool; 20 | 21 | @PostConstruct 22 | public void init() { 23 | log.info("started mart crawler scheduler"); 24 | } 25 | 26 | @Scheduled(cron = "${schedule.cron.crawler:0 0 3 ? * *}") 27 | public void crawlMarts() throws Exception { 28 | startCrawlers(); 29 | awaitCrawlers(); 30 | } 31 | 32 | private void startCrawlers() { 33 | Arrays.stream(MartCrawlerType.values()) 34 | .forEach(martCrawlerAsyncService::crawl); 35 | } 36 | 37 | private void awaitCrawlers() throws Exception { 38 | Duration threadWaiting = Duration.ofSeconds(20); 39 | while (true) { 40 | int activeCount = crawlerThreadPool.getActiveCount(); 41 | if (activeCount == 0) break; 42 | 43 | log.info("[CRAWLING] wait for crawlers to finish. active count : {}", activeCount); 44 | Thread.sleep(threadWaiting.toMillis()); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/utils/JsonParser.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils; 2 | 3 | import com.fasterxml.jackson.core.type.TypeReference; 4 | import com.fasterxml.jackson.databind.JsonNode; 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import com.hongsi.martholidayalarm.crawler.exception.CrawlerDataParseException; 7 | import com.hongsi.martholidayalarm.crawler.exception.PageNotFoundException; 8 | import lombok.extern.slf4j.Slf4j; 9 | 10 | import java.io.IOException; 11 | import java.net.MalformedURLException; 12 | import java.net.URL; 13 | 14 | @Slf4j 15 | public class JsonParser { 16 | 17 | private static final ObjectMapper objectMapper = new ObjectMapper(); 18 | 19 | public static T request(String pageUrl, Class type) { 20 | return objectMapper.convertValue(request(pageUrl), type); 21 | } 22 | 23 | public static JsonNode request(String pageUrl) { 24 | try { 25 | URL url = new URL(pageUrl); 26 | return objectMapper.readTree(url); 27 | } catch (MalformedURLException e) { 28 | log.error("can't find page : {}, message : {}", pageUrl, 29 | e.getMessage()); 30 | throw new PageNotFoundException(); 31 | } catch (IOException e) { 32 | log.error("can't get page : {}, message : {}", pageUrl, 33 | e.getMessage()); 34 | throw new CrawlerDataParseException(); 35 | } 36 | } 37 | 38 | public static T convert(JsonNode from, Class type) { 39 | return objectMapper.convertValue(from, type); 40 | } 41 | 42 | public static T convert(JsonNode from, TypeReference type) { 43 | return objectMapper.convertValue(from, type); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/interceptor/ApiLogInterceptor.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.interceptor; 2 | 3 | import com.hongsi.martholidayalarm.api.dto.ApiLog; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.stereotype.Component; 6 | import org.springframework.util.StringUtils; 7 | import org.springframework.web.servlet.HandlerMapping; 8 | import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; 9 | 10 | import javax.servlet.http.HttpServletRequest; 11 | import javax.servlet.http.HttpServletResponse; 12 | 13 | @Component 14 | @Slf4j 15 | public class ApiLogInterceptor extends HandlerInterceptorAdapter { 16 | 17 | @Override 18 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 19 | ApiLog apiLog = ApiLog.builder() 20 | .remoteAddr(getRemoteIp(request)) 21 | .httpMethod(request.getMethod()) 22 | .urlPattern((String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE)) 23 | .queryString(request.getQueryString()) 24 | .build(); 25 | log.info(apiLog.toString()); 26 | return super.preHandle(request, response, handler); 27 | } 28 | 29 | private String getRemoteIp(HttpServletRequest request) { 30 | String ip = request.getHeader("x-forwarded-for"); 31 | if (StringUtils.isEmpty(ip)) { 32 | ip = request.getHeader("x-real-ip"); 33 | } 34 | if (StringUtils.isEmpty(ip)) { 35 | ip = request.getRemoteAddr(); 36 | } 37 | return ip; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/service/MartPusher.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.service; 2 | 3 | import com.google.firebase.database.DataSnapshot; 4 | import com.google.firebase.database.DatabaseError; 5 | import com.google.firebase.database.ValueEventListener; 6 | import com.hongsi.martholidayalarm.push.model.PushUser; 7 | import com.hongsi.martholidayalarm.push.repository.PushUserRepository; 8 | import lombok.RequiredArgsConstructor; 9 | import lombok.extern.slf4j.Slf4j; 10 | import org.springframework.stereotype.Service; 11 | 12 | @Service 13 | @RequiredArgsConstructor 14 | @Slf4j 15 | public class MartPusher { 16 | 17 | private final PushUserRepository pushUserRepository; 18 | private final MartPushAsyncService martPushAsyncService; 19 | 20 | public void pushToUsers() { 21 | pushUserRepository.findAll( 22 | new ValueEventListener() { 23 | @Override 24 | public void onDataChange(DataSnapshot snapshot) { 25 | for (DataSnapshot userSnapshot : snapshot.getChildren()) { 26 | PushUser pushUser = userSnapshot.getValue(PushUser.class); 27 | pushUser.setDeviceToken(userSnapshot.getKey()); 28 | martPushAsyncService.push(pushUser); 29 | } 30 | } 31 | 32 | @Override 33 | public void onCancelled(DatabaseError error) { 34 | log.error("failed to get push user. exception: {}, message: {}", error.toException(), error.getMessage()); 35 | } 36 | } 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/model/MartPushMessage.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.model; 2 | 3 | import com.google.firebase.messaging.ApnsConfig; 4 | import com.google.firebase.messaging.Aps; 5 | import com.google.firebase.messaging.Message; 6 | import com.google.firebase.messaging.Notification; 7 | import lombok.EqualsAndHashCode; 8 | import lombok.ToString; 9 | 10 | @EqualsAndHashCode 11 | @ToString 12 | public class MartPushMessage { 13 | 14 | private static final ApnsConfig APNS_CONFIG = ApnsConfig.builder() 15 | .setAps(Aps.builder() 16 | .setSound("default") 17 | .setThreadId("martholidayapp") 18 | .build()) 19 | .build(); 20 | 21 | private final String deviceToken; 22 | private final String title; 23 | private final String message; 24 | 25 | MartPushMessage(String deviceToken, String title, String message) { 26 | this.deviceToken = deviceToken; 27 | this.title = title; 28 | this.message = message; 29 | } 30 | 31 | public static MartPushMessage of(String deviceToken, PushMart pushMart) { 32 | String title = String.format("%s %s", pushMart.getMartType(), pushMart.getBranchName()); 33 | String message = String.format("내일(%s)은 쉬는 날이에요!! 🏖", pushMart.getFormattedHoliday()); 34 | return new MartPushMessage(deviceToken, title, message); 35 | } 36 | 37 | public Message toFirebaseMessage() { 38 | return Message.builder() 39 | .setToken(deviceToken) 40 | .setNotification(new Notification(title, message)) 41 | .setApnsConfig(APNS_CONFIG) 42 | .build(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/MartCrawlerType.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler; 2 | 3 | import com.hongsi.martholidayalarm.crawler.model.costco.CostcoCrawler; 4 | import com.hongsi.martholidayalarm.crawler.model.emart.EmartCrawler; 5 | import com.hongsi.martholidayalarm.crawler.model.emart.EmartTradersCrawler; 6 | import com.hongsi.martholidayalarm.crawler.model.emart.NobrandCrawler; 7 | import com.hongsi.martholidayalarm.crawler.model.homeplus.HomePlusCrawler; 8 | import com.hongsi.martholidayalarm.crawler.model.homeplus.HomePlusExpressCrawler; 9 | import com.hongsi.martholidayalarm.crawler.model.lottemart.LotteMartCrawler; 10 | import lombok.Getter; 11 | 12 | public enum MartCrawlerType { 13 | 14 | EMART(EmartCrawler.class, "https://store.emart.com"), 15 | EMART_TRADERS(EmartTradersCrawler.class, "https://store.traders.co.kr"), 16 | NOBRAND(NobrandCrawler.class, EMART.url), 17 | LOTTEMART(LotteMartCrawler.class, "http://company.lottemart.com"), 18 | HOMEPLUS(HomePlusCrawler.class, "https://corporate.homeplus.co.kr"), 19 | HOMEPLUS_EXPRESS(HomePlusExpressCrawler.class, HOMEPLUS.url), 20 | COSTCO(CostcoCrawler.class, "https://www.costco.co.kr"); 21 | 22 | private static final String SLASH = "/"; 23 | 24 | @Getter 25 | private final Class martCrawler; 26 | private final String url; 27 | 28 | MartCrawlerType(Class martCrawler, String url) { 29 | this.martCrawler = martCrawler; 30 | this.url = url; 31 | } 32 | 33 | public String appendUrl(String suffix) { 34 | StringBuilder url = new StringBuilder(this.url); 35 | if (!suffix.startsWith(SLASH)) { 36 | url.append(SLASH); 37 | } 38 | return url.append(suffix).toString(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/holiday/KoreanDayOfWeek.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday; 2 | 3 | import com.hongsi.martholidayalarm.crawler.utils.MatchSpliterator; 4 | 5 | import java.time.DayOfWeek; 6 | import java.time.format.TextStyle; 7 | import java.util.Arrays; 8 | import java.util.List; 9 | import java.util.Locale; 10 | import java.util.regex.Matcher; 11 | import java.util.regex.Pattern; 12 | import java.util.stream.Collectors; 13 | 14 | public enum KoreanDayOfWeek { 15 | 16 | MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY; 17 | 18 | private static final Pattern DAY_OF_WEEK_PATTERN = Pattern.compile(".요일"); 19 | 20 | private final String character; 21 | 22 | KoreanDayOfWeek() { 23 | character = DayOfWeek.valueOf(name()).getDisplayName(TextStyle.FULL, Locale.KOREA); 24 | } 25 | 26 | public static List parseToCollection(String text) { 27 | Matcher matcher = DAY_OF_WEEK_PATTERN.matcher(text); 28 | return MatchSpliterator.from(matcher).stream() 29 | .map(KoreanDayOfWeek::of) 30 | .collect(Collectors.toList()); 31 | } 32 | 33 | public static KoreanDayOfWeek of(String text) { 34 | return Arrays.stream(values()) 35 | .filter(dayOfWeek -> dayOfWeek.startsWith(text)) 36 | .findFirst() 37 | .orElseThrow(() -> new IllegalArgumentException("Not found DayOfWeek of this text : " + text)); 38 | } 39 | 40 | private boolean startsWith(String text) { 41 | return character.startsWith(text); 42 | } 43 | 44 | public DayOfWeek getDayOfWeek() { 45 | return DayOfWeek.valueOf(name()); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/holiday/RegularHoliday.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday; 2 | 3 | import java.time.DayOfWeek; 4 | import java.time.LocalDate; 5 | import java.time.YearMonth; 6 | import java.time.temporal.TemporalAdjusters; 7 | import java.util.Objects; 8 | 9 | public class RegularHoliday { 10 | 11 | private static final int FIRST_DAY_IN_MONTH = 1; 12 | 13 | private final int week; 14 | private final DayOfWeek dayOfWeek; 15 | 16 | private RegularHoliday(KoreanWeek koreanWeek, KoreanDayOfWeek koreanDayOfWeek) { 17 | week = koreanWeek.getWeek(); 18 | dayOfWeek = koreanDayOfWeek.getDayOfWeek(); 19 | } 20 | 21 | public static RegularHoliday of(KoreanWeek koreanWeek, KoreanDayOfWeek koreanDayOfWeek) { 22 | if (koreanWeek == null || koreanDayOfWeek == null) { 23 | throw new IllegalArgumentException("Can't create RegularHoliday"); 24 | } 25 | return new RegularHoliday(koreanWeek, koreanDayOfWeek); 26 | } 27 | 28 | public LocalDate getDate(YearMonth yearMonth) { 29 | return yearMonth.atDay(FIRST_DAY_IN_MONTH) 30 | .with(TemporalAdjusters.dayOfWeekInMonth(week, dayOfWeek)); 31 | } 32 | 33 | @Override 34 | public boolean equals(Object o) { 35 | if (this == o) { 36 | return true; 37 | } 38 | if (!(o instanceof RegularHoliday)) { 39 | return false; 40 | } 41 | RegularHoliday that = (RegularHoliday) o; 42 | return week == that.week && 43 | dayOfWeek == that.dayOfWeek; 44 | } 45 | 46 | @Override 47 | public int hashCode() { 48 | return Objects.hash(week, dayOfWeek); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/emart/EmartHolidayParser.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.emart; 2 | 3 | import com.fasterxml.jackson.annotation.JsonAlias; 4 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 5 | import com.fasterxml.jackson.annotation.JsonProperty; 6 | import com.hongsi.martholidayalarm.core.holiday.Holiday; 7 | import lombok.ToString; 8 | 9 | import java.time.LocalDate; 10 | import java.time.format.DateTimeFormatter; 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | 14 | @ToString 15 | @JsonIgnoreProperties(ignoreUnknown = true) 16 | public class EmartHolidayParser { 17 | 18 | private static final DateTimeFormatter HOLIDAY_FORMATTER = DateTimeFormatter 19 | .ofPattern("yyyyMMdd"); 20 | private static final int VALID_DATE_LENGTH = 8; 21 | 22 | @JsonProperty("JIJUM_ID") 23 | private String realId; 24 | 25 | private final List holidays = new ArrayList<>(); 26 | 27 | public boolean isSameId(String realId) { 28 | if (this.realId == null || realId == null) { 29 | return false; 30 | } 31 | return this.realId.equals(realId); 32 | } 33 | 34 | public String getRealId() { 35 | return realId; 36 | } 37 | 38 | public void setRealId(String realId) { 39 | this.realId = realId; 40 | } 41 | 42 | public List getHolidays() { 43 | return holidays; 44 | } 45 | 46 | @JsonAlias({"HOLIDAY_DAY1_YMD", "HOLIDAY_DAY2_YMD", "HOLIDAY_DAY3_YMD"}) 47 | public void setHolidays(String rawHoliday) { 48 | if (rawHoliday.length() == VALID_DATE_LENGTH) { 49 | LocalDate date = LocalDate.parse(rawHoliday, HOLIDAY_FORMATTER); 50 | holidays.add(Holiday.of(date)); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-location-converter/src/main/java/com/hongsi/martholidayalarm/client/location/converter/config/ClientConfig.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.location.converter.config; 2 | 3 | import com.hongsi.martholidayalarm.client.location.converter.LocationConverter; 4 | import com.hongsi.martholidayalarm.client.location.converter.LocationConverterProperties; 5 | import com.hongsi.martholidayalarm.client.location.converter.kakao.KakaoLocationConverter; 6 | import org.springframework.boot.context.properties.ConfigurationProperties; 7 | import org.springframework.boot.web.client.RestTemplateBuilder; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | import org.springframework.web.client.RestTemplate; 11 | 12 | import java.time.Duration; 13 | 14 | @Configuration 15 | public class ClientConfig { 16 | 17 | private static final int DEFAULT_TIMEOUT_SECONDS = 3 * 1000; 18 | 19 | @Bean 20 | @ConfigurationProperties(prefix = "kakao.client") 21 | public LocationConverterProperties kakaoProperties() { 22 | return new LocationConverterProperties(); 23 | } 24 | 25 | @Bean 26 | public LocationConverter locationConverter(RestTemplateBuilder restTemplateBuilder, 27 | LocationConverterProperties LocationConverterProperties) { 28 | return new KakaoLocationConverter( 29 | restTemplateBuilder 30 | .setConnectTimeout(Duration.ofMillis(DEFAULT_TIMEOUT_SECONDS)) 31 | .setReadTimeout(Duration.ofMillis(DEFAULT_TIMEOUT_SECONDS)), 32 | LocationConverterProperties 33 | ); 34 | } 35 | 36 | @Bean 37 | public RestTemplate restTemplate() { 38 | return new RestTemplate(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/test/java/com/hongsi/martholidayalarm/core/mart/MartRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.mart; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 7 | 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 10 | 11 | @DataJpaTest 12 | public class MartRepositoryTest { 13 | 14 | @Autowired 15 | private MartRepository martRepository; 16 | 17 | @BeforeEach 18 | public void setUp() { 19 | martRepository.deleteAll(); 20 | } 21 | 22 | @Test 23 | void 타입과_realId가_다르면_저장() { 24 | Mart newMart1 = Mart.builder() 25 | .martType(MartType.EMART) 26 | .realId("1") 27 | .build(); 28 | martRepository.save(newMart1); 29 | 30 | Mart newMart2 = Mart.builder() 31 | .martType(MartType.LOTTEMART) 32 | .realId(newMart1.getRealId()) 33 | .build(); 34 | martRepository.save(newMart2); 35 | 36 | assertThat(martRepository.findAll()) 37 | .hasSize(2) 38 | .contains(newMart2); 39 | } 40 | 41 | @Test 42 | void 타입과_realId가_같으면_에러() { 43 | Mart savedMart = Mart.builder() 44 | .martType(MartType.EMART) 45 | .realId("1") 46 | .build(); 47 | martRepository.save(savedMart); 48 | 49 | assertThatThrownBy(() -> martRepository.save( 50 | Mart.builder() 51 | .martType(savedMart.getMartType()) 52 | .realId(savedMart.getRealId()) 53 | .build()) 54 | ); 55 | } 56 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/holiday/KoreanDayOfWeekTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday 2 | 3 | import spock.lang.Specification 4 | 5 | import static com.hongsi.martholidayalarm.crawler.model.holiday.KoreanDayOfWeek.* 6 | 7 | class KoreanDayOfWeekTest extends Specification { 8 | 9 | def "Should parse day of week from single korean character"() { 10 | expect: 11 | of(text) == expected 12 | 13 | where: 14 | text || expected 15 | "월" || MONDAY 16 | "화" || TUESDAY 17 | "수" || WEDNESDAY 18 | "목" || THURSDAY 19 | "금" || FRIDAY 20 | "토" || SATURDAY 21 | "일" || SUNDAY 22 | } 23 | 24 | def "Should parse day of week from full korean character"() { 25 | expect: 26 | of(text) == expected 27 | 28 | where: 29 | text || expected 30 | "월요일" || MONDAY 31 | "화요일" || TUESDAY 32 | "수요일" || WEDNESDAY 33 | "목요일" || THURSDAY 34 | "금요일" || FRIDAY 35 | "토요일" || SATURDAY 36 | "일요일" || SUNDAY 37 | } 38 | 39 | def "Shouldn't parse day of week from invalid text"() { 40 | when: 41 | of(text) 42 | 43 | then: 44 | thrown(IllegalArgumentException) 45 | 46 | where: 47 | text | _ 48 | "잘" | _ 49 | "월월" | _ 50 | "왈요일" | _ 51 | } 52 | 53 | def "Should parse to list from text"() { 54 | expect: 55 | parseToCollection(text) == expected 56 | 57 | where: 58 | text || expected 59 | "월요일" || [MONDAY] 60 | "월요일수요일" || [MONDAY, WEDNESDAY] 61 | "월요일,수요일" || [MONDAY, WEDNESDAY] 62 | "월요일, 수요일" || [MONDAY, WEDNESDAY] 63 | "월요일,수요일,금요일" || [MONDAY, WEDNESDAY, FRIDAY] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/holiday/RegularHolidayParser.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday; 2 | 3 | import java.time.LocalDate; 4 | import java.time.YearMonth; 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import java.util.stream.Collectors; 8 | 9 | public class RegularHolidayParser { 10 | 11 | private static final int ADDITION_MONTH_COUNT = 1; 12 | private static final int MAX_ADDITION_MONTH_COUNT = ADDITION_MONTH_COUNT + 1; 13 | private static final int MAX_DAYS = 31 * ADDITION_MONTH_COUNT; 14 | 15 | private final List yearMonths; 16 | private final LocalDateRange range; 17 | 18 | private RegularHolidayParser(List yearMonths, LocalDateRange range) { 19 | this.yearMonths = yearMonths; 20 | this.range = range; 21 | } 22 | 23 | public static RegularHolidayParser from(LocalDate date) { 24 | List yearMonths = parseYearMonths(date); 25 | LocalDateRange range = LocalDateRange.withDays(date, MAX_DAYS); 26 | return new RegularHolidayParser(yearMonths, range); 27 | } 28 | 29 | private static List parseYearMonths(LocalDate holiday) { 30 | List yearMonths = new ArrayList<>(MAX_ADDITION_MONTH_COUNT); 31 | YearMonth current = YearMonth.from(holiday); 32 | YearMonth end = current.plusMonths(ADDITION_MONTH_COUNT); 33 | while (!current.isAfter(end)) { 34 | yearMonths.add(current); 35 | current = current.plusMonths(1); 36 | } 37 | return yearMonths; 38 | } 39 | 40 | public List parse(RegularHoliday regularHoliday) { 41 | return yearMonths.stream() 42 | .map(regularHoliday::getDate) 43 | .filter(range::isBetween) 44 | .collect(Collectors.toList()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/domain/InvalidCrawledMartTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.domain 2 | 3 | import com.hongsi.martholidayalarm.core.mart.MartType 4 | import spock.lang.Specification 5 | import spock.lang.Unroll 6 | 7 | class InvalidCrawledMartTest extends Specification { 8 | 9 | def "Should be required martType and realId"() { 10 | given: 11 | def builder = InvalidCrawledMart.builder() 12 | .martType(martType) 13 | .realId(realId) 14 | 15 | when: 16 | builder.build() 17 | 18 | then: 19 | thrown(IllegalArgumentException.class) 20 | 21 | where: 22 | martType | realId 23 | null | null 24 | MartType.EMART | null 25 | null | "1" 26 | } 27 | 28 | @Unroll 29 | def "Should check for invalid mart"() { 30 | given: 31 | def invalidMart = InvalidCrawledMart.builder() 32 | .martType(MartType.EMART) 33 | .realId("1") 34 | .enable(enable) 35 | .build() 36 | 37 | when: 38 | def invalid = invalidMart.isInvalid(checkMartType, checkRealId) 39 | 40 | then: 41 | invalid == expected 42 | 43 | where: 44 | enable | checkMartType | checkRealId || expected 45 | true | MartType.EMART | "1" || true 46 | true | MartType.EMART | "2" || false 47 | true | MartType.LOTTEMART | "1" || false 48 | true | MartType.LOTTEMART | "2" || false 49 | 50 | false | MartType.EMART | "1" || false 51 | false | MartType.EMART | "2" || false 52 | false | MartType.LOTTEMART | "1" || false 53 | false | MartType.LOTTEMART | "2" || false 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/ErrorMessages.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto; 2 | 3 | import lombok.Getter; 4 | import lombok.ToString; 5 | import org.springframework.beans.TypeMismatchException; 6 | import org.springframework.validation.BindingResult; 7 | import org.springframework.validation.FieldError; 8 | 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | import java.util.stream.Collectors; 12 | 13 | @Getter 14 | @ToString 15 | public class ErrorMessages { 16 | 17 | private final List errors = new ArrayList<>(); 18 | 19 | public ErrorMessages(BindingResult bindingResult) { 20 | errors.addAll(parseFieldErrors(bindingResult)); 21 | } 22 | 23 | private List parseFieldErrors(BindingResult bindingResult) { 24 | return bindingResult.getFieldErrors().stream() 25 | .map(ErrorMessage::new) 26 | .collect(Collectors.toList()); 27 | } 28 | 29 | @Getter 30 | @ToString 31 | public static class ErrorMessage { 32 | 33 | private final String field; 34 | private final String message; 35 | 36 | public ErrorMessage(FieldError fieldError) { 37 | this.field = fieldError.getField(); 38 | this.message = getMessageFromError(fieldError); 39 | } 40 | 41 | private String getMessageFromError(FieldError filedError) { 42 | try { 43 | // TypeMismatchException 은 메시지를 알아볼 수 없기 때문에 알아볼 수 있는 메세지로 별도 처리 44 | TypeMismatchException exception = filedError.unwrap(TypeMismatchException.class); 45 | return String.format("%s 타입만 가능합니다.", exception.getRequiredType().getSimpleName()); 46 | } catch (Exception e) { 47 | // 그 외는 메세지 기본 설정된 메세지 사용 48 | return filedError.getDefaultMessage(); 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/utils/MatchSpliterator.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.utils; 2 | 3 | import java.util.Spliterators.AbstractSpliterator; 4 | import java.util.function.Consumer; 5 | import java.util.regex.Matcher; 6 | import java.util.regex.Pattern; 7 | import java.util.stream.Stream; 8 | import java.util.stream.StreamSupport; 9 | 10 | public class MatchSpliterator extends AbstractSpliterator { 11 | 12 | private static final int NOT_USE = -1; 13 | 14 | private final Matcher matcher; 15 | private final int groupIndex; 16 | 17 | public MatchSpliterator(Matcher matcher) { 18 | this(matcher, NOT_USE); 19 | } 20 | 21 | public MatchSpliterator(Matcher matcher, int groupIndex) { 22 | super(matcher.regionEnd() - matcher.regionStart(), ORDERED | IMMUTABLE); 23 | this.matcher = matcher; 24 | this.groupIndex = groupIndex; 25 | } 26 | 27 | public static MatchSpliterator from(Matcher matcher) { 28 | return new MatchSpliterator(matcher); 29 | } 30 | 31 | public static MatchSpliterator from(Pattern pattern, String text) { 32 | return new MatchSpliterator(pattern.matcher(text)); 33 | } 34 | 35 | public static MatchSpliterator from(Pattern pattern, String text, int groupIndex) { 36 | return new MatchSpliterator(pattern.matcher(text), groupIndex); 37 | } 38 | 39 | @Override 40 | public boolean tryAdvance(Consumer action) { 41 | if (!matcher.find()) { 42 | return false; 43 | } 44 | 45 | if (groupIndex == NOT_USE) { 46 | action.accept(matcher.group()); 47 | } else { 48 | action.accept(matcher.group(groupIndex)); 49 | } 50 | return true; 51 | } 52 | 53 | public Stream stream() { 54 | return StreamSupport.stream(this, false); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/test/groovy/com/hongsi/martholidayalarm/push/service/MartPushAsyncServiceTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.service 2 | 3 | import com.hongsi.martholidayalarm.clients.firebase.message.FirebaseMessageSender 4 | import com.hongsi.martholidayalarm.core.holiday.Holiday 5 | import com.hongsi.martholidayalarm.core.mart.MartType 6 | import com.hongsi.martholidayalarm.push.model.PushMart 7 | import com.hongsi.martholidayalarm.push.model.PushUser 8 | import com.hongsi.martholidayalarm.push.repository.PushMartRepository 9 | import com.hongsi.martholidayalarm.push.repository.PushUserRepository 10 | import spock.lang.Specification 11 | import spock.lang.Unroll 12 | 13 | class MartPushAsyncServiceTest extends Specification { 14 | 15 | private PushMartRepository pushMartRepository 16 | private PushUserRepository pushUserRepository 17 | private FirebaseMessageSender sender 18 | 19 | private MartPushAsyncService martPushService 20 | 21 | void setup() { 22 | pushMartRepository = Mock() 23 | pushUserRepository = Mock() 24 | sender = Mock() 25 | martPushService = new MartPushAsyncService(pushMartRepository, pushUserRepository, sender) 26 | } 27 | 28 | @Unroll 29 | def "사용자가 즐겨찾기한 마트에게 푸시를 보낼 수 있다"() { 30 | given: 31 | def pushUser = new PushUser() 32 | pushUser.setDeviceToken("deviceToken") 33 | pushUser.setFavorites(favorites) 34 | 1 * pushMartRepository.findAllByIdInAndHolidayDate(pushUser.getFavorites(), _) >> pushMarts 35 | 36 | when: 37 | martPushService.push(pushUser) 38 | 39 | then: 40 | pushMarts.size() * sender.send(_) 41 | 42 | where: 43 | favorites || pushMarts 44 | [1, 2, 3] || [new PushMart(1, MartType.EMART, "성수점", Holiday.of(2020, 5, 31)), new PushMart(2, MartType.LOTTEMART, "구로점", Holiday.of(2020, 5, 31))] 45 | [1, 2, 3] || [] 46 | [] || [] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/holiday/RegularHolidayTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday 2 | 3 | import com.hongsi.martholidayalarm.crawler.model.holiday.RegularHoliday 4 | import spock.lang.Specification 5 | 6 | import java.time.LocalDate 7 | import java.time.YearMonth 8 | 9 | import static com.hongsi.martholidayalarm.crawler.model.holiday.KoreanDayOfWeek.* 10 | import static com.hongsi.martholidayalarm.crawler.model.holiday.KoreanWeek.* 11 | 12 | class RegularHolidayTest extends Specification { 13 | 14 | def "Should be equal holiday with same week and day of week"() { 15 | given: 16 | def week = FIRST 17 | def dayOfWeek = SUNDAY 18 | 19 | expect: 20 | RegularHoliday.of(week, dayOfWeek) == RegularHoliday.of(week, dayOfWeek) 21 | } 22 | 23 | def "Shouldn't be created, if all parameter don't exist"() { 24 | when: 25 | RegularHoliday.of(koreanWeek, koreanDayOfWeek) 26 | 27 | then: 28 | thrown(IllegalArgumentException) 29 | 30 | where: 31 | koreanWeek | koreanDayOfWeek | _ 32 | null | null | _ 33 | FIRST | null | _ 34 | null | MONDAY | _ 35 | } 36 | 37 | def "Should get Nth day of week in month"() { 38 | given: 39 | def regularHoliday = RegularHoliday.of(week, dayOfWeek) 40 | 41 | when: 42 | def actual = regularHoliday.getDate(yearMonth) 43 | 44 | then: 45 | actual == expected 46 | 47 | where: 48 | week | dayOfWeek | yearMonth || expected 49 | FIRST | SUNDAY | YearMonth.of(2019, 10) || LocalDate.of(2019, 10, 6) 50 | SECOND | WEDNESDAY | YearMonth.of(2019, 10) || LocalDate.of(2019, 10, 9) 51 | THIRD | MONDAY | YearMonth.of(2019, 10) || LocalDate.of(2019, 10, 21) 52 | FOURTH | TUESDAY | YearMonth.of(2019, 10) || LocalDate.of(2019, 10, 22) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/holiday/KoreanWeek.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday; 2 | 3 | import com.hongsi.martholidayalarm.crawler.utils.MatchSpliterator; 4 | 5 | import java.util.ArrayList; 6 | import java.util.Arrays; 7 | import java.util.List; 8 | import java.util.regex.Matcher; 9 | import java.util.regex.Pattern; 10 | import java.util.stream.Collectors; 11 | 12 | public enum KoreanWeek { 13 | 14 | FIRST("첫"), SECOND("둘", "두"), THIRD("셋", "세"), FOURTH("넷", "네"), FIFTH("다섯"); 15 | 16 | private static final String REGEX_DELIMITER = "|"; 17 | private static final Pattern WEEK_PATTERN = Pattern.compile( 18 | Arrays.stream(KoreanWeek.values()) 19 | .flatMap(koreanWeek -> koreanWeek.characters.stream()) 20 | .collect(Collectors.joining(REGEX_DELIMITER)) 21 | ); 22 | 23 | private final List characters; 24 | 25 | KoreanWeek(String... characters) { 26 | this.characters = new ArrayList<>(characters.length + 1); 27 | this.characters.addAll(Arrays.asList(characters)); 28 | this.characters.add(Integer.toString(getWeek())); 29 | } 30 | 31 | public static KoreanWeek of(String text) { 32 | return Arrays.stream(values()) 33 | .filter(weekWrapper -> weekWrapper.hasCharacter(text)) 34 | .findFirst() 35 | .orElseThrow(() -> new IllegalArgumentException("Not found week of this text : " + text)); 36 | } 37 | 38 | public static List parseToCollection(String text) { 39 | Matcher matcher = WEEK_PATTERN.matcher(text); 40 | return MatchSpliterator.from(matcher).stream() 41 | .map(KoreanWeek::of) 42 | .collect(Collectors.toList()); 43 | } 44 | 45 | public int getWeek() { 46 | return ordinal() + 1; 47 | } 48 | 49 | private boolean hasCharacter(String text) { 50 | return characters.contains(text); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-firebase/src/main/java/com/hongsi/martholidayalarm/clients/firebase/FirebaseAppInitializer.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.clients.firebase; 2 | 3 | import com.google.auth.oauth2.GoogleCredentials; 4 | import com.google.firebase.FirebaseApp; 5 | import com.google.firebase.FirebaseOptions; 6 | import lombok.RequiredArgsConstructor; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.stereotype.Component; 10 | 11 | import javax.annotation.PostConstruct; 12 | import java.io.FileInputStream; 13 | import java.io.IOException; 14 | import java.util.Collections; 15 | import java.util.List; 16 | 17 | @Slf4j 18 | @Component 19 | @RequiredArgsConstructor 20 | public class FirebaseAppInitializer { 21 | 22 | private static final List SCOPES = Collections.singletonList("https://www.googleapis.com/auth/firebase.messaging"); 23 | 24 | @Value("${firebase.project-id}") 25 | private String projectId; 26 | @Value("${firebase.service-key-file}") 27 | private String serviceKeyFile; 28 | 29 | @PostConstruct 30 | public void initialize() throws Exception { 31 | try { 32 | FirebaseOptions options = new FirebaseOptions.Builder() 33 | .setProjectId(projectId) 34 | .setCredentials(GoogleCredentials 35 | .fromStream(new FileInputStream(serviceKeyFile)) 36 | .createScoped(SCOPES)) 37 | .setDatabaseUrl("https://" + projectId + ".firebaseio.com") 38 | .build(); 39 | if (FirebaseApp.getApps().isEmpty()) { 40 | log.info("Initialize firebase app! projectId: {}, serviceKeyFile: {}", projectId, serviceKeyFile); 41 | FirebaseApp.initializeApp(options); 42 | } 43 | } catch (IOException e) { 44 | log.error("Failed to initialize firebase app", e); 45 | throw e; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/test/groovy/com/hongsi/martholidayalarm/core/holiday/HolidayTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.holiday 2 | 3 | import spock.lang.Specification 4 | 5 | import java.time.LocalDate 6 | 7 | class HolidayTest extends Specification { 8 | 9 | def "Should create from date"() { 10 | given: 11 | def year = 2018 12 | def month = 1 13 | def dayOfMonth = 11 14 | 15 | expect: 16 | Holiday.of(year, month, dayOfMonth) == Holiday.of(LocalDate.of(year, month, dayOfMonth)) 17 | } 18 | 19 | def "Should be upcoming when holiday is equal or after current date"() { 20 | given: 21 | def holiday = Holiday.of(date) 22 | 23 | when: 24 | def actual = holiday.isUpcoming() 25 | 26 | then: 27 | actual == expected 28 | 29 | where: 30 | date || expected 31 | LocalDate.now() || true 32 | LocalDate.now().plusDays(1) || true 33 | LocalDate.now().minusDays(1) || false 34 | } 35 | 36 | def "Format holiday to string"() { 37 | given: 38 | def holiday = Holiday.of(year, month, dayOfMonth) 39 | 40 | expect: 41 | holiday.getFormattedHoliday() == expected 42 | 43 | where: 44 | year | month | dayOfMonth || expected 45 | 2019 | 1 | 1 || "2019-01-01 (화)" 46 | 2019 | 10 | 12 || "2019-10-12 (토)" 47 | } 48 | 49 | def "Compare holiday"() { 50 | given: 51 | def holiday1 = Holiday.of(2019, 1, day1) 52 | def holiday2 = Holiday.of(2019, 1, day2) 53 | 54 | expect: 55 | holiday1 < holiday2 == isBefore 56 | holiday1 == holiday2 == isEqual 57 | holiday1 > holiday2 == isAfter 58 | 59 | where: 60 | day1 | day2 || isBefore | isEqual | isAfter 61 | 1 | 1 || false | true | false 62 | 1 | 2 || true | false | false 63 | 2 | 1 || false | false | true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/domain/InvalidCrawledMart.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.domain; 2 | 3 | import com.hongsi.martholidayalarm.core.BaseEntity; 4 | import com.hongsi.martholidayalarm.core.mart.MartType; 5 | import lombok.Builder; 6 | import lombok.NoArgsConstructor; 7 | 8 | import javax.persistence.*; 9 | import java.util.Objects; 10 | 11 | @Entity 12 | @Table(uniqueConstraints = { 13 | @UniqueConstraint(columnNames = {"martType", "realId"}) 14 | }) 15 | @NoArgsConstructor 16 | public class InvalidCrawledMart extends BaseEntity { 17 | 18 | @Id 19 | @GeneratedValue(strategy = GenerationType.IDENTITY) 20 | private Long id; 21 | 22 | @Column(nullable = false) 23 | @Enumerated(EnumType.STRING) 24 | private MartType martType; 25 | 26 | @Column(nullable = false) 27 | private String realId; 28 | 29 | @Column(nullable = false) 30 | private Boolean enable; 31 | 32 | @Builder 33 | public InvalidCrawledMart(MartType martType, String realId, Boolean enable) { 34 | if (martType == null || realId == null) { 35 | throw new IllegalArgumentException("MartType and RealId must be non-null"); 36 | } 37 | this.martType = martType; 38 | this.realId = realId; 39 | this.enable = enable != null ? enable : Boolean.TRUE; 40 | } 41 | 42 | public boolean isInvalid(MartType martType, String realId) { 43 | if (!enable) { 44 | return false; 45 | } 46 | return this.martType == martType && this.realId.equals(realId); 47 | } 48 | 49 | @Override 50 | public boolean equals(Object o) { 51 | if (this == o) return true; 52 | if (o == null || getClass() != o.getClass()) return false; 53 | InvalidCrawledMart that = (InvalidCrawledMart) o; 54 | return martType == that.martType && 55 | Objects.equals(realId, that.realId); 56 | } 57 | 58 | @Override 59 | public int hashCode() { 60 | return Objects.hash(martType, realId); 61 | } 62 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/java/com/hongsi/martholidayalarm/api/docs/CommonApiDocumentConfigure.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.docs; 2 | 3 | import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; 4 | import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; 5 | import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; 6 | import org.springframework.restdocs.payload.FieldDescriptor; 7 | import org.springframework.restdocs.payload.ResponseFieldsSnippet; 8 | 9 | import java.util.List; 10 | 11 | import static java.util.Arrays.asList; 12 | import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; 13 | import static org.springframework.restdocs.payload.PayloadDocumentation.beneathPath; 14 | import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; 15 | 16 | @AutoConfigureRestDocs(uriScheme = "https", uriHost = "docs.api.com") 17 | public class CommonApiDocumentConfigure { 18 | 19 | protected OperationRequestPreprocessor getDocumentRequest() { 20 | return preprocessRequest( 21 | modifyUris() 22 | .scheme("https") 23 | .host("docs.api.com") 24 | .removePort(), 25 | prettyPrint()); 26 | } 27 | 28 | protected OperationResponsePreprocessor getDocumentResponse() { 29 | return preprocessResponse(prettyPrint()); 30 | } 31 | 32 | protected ResponseFieldsSnippet apiResponseFieldSnippet(boolean isArray, FieldDescriptor... fieldDescriptors) { 33 | return apiResponseFieldSnippet(isArray, asList(fieldDescriptors)); 34 | } 35 | 36 | protected ResponseFieldsSnippet apiResponseFieldSnippet(boolean isArray, List fieldDescriptors) { 37 | String path = "data"; 38 | if (isArray) { 39 | path += "[]"; 40 | } 41 | return responseFields( 42 | beneathPath(path).withSubsectionId("data"), 43 | fieldDescriptors 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/controller/advice/CommonControllerAdvice.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller.advice; 2 | 3 | import com.hongsi.martholidayalarm.api.dto.ApiException; 4 | import com.hongsi.martholidayalarm.api.dto.ApiResponseCode; 5 | import com.hongsi.martholidayalarm.api.dto.ErrorMessages; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.validation.BindException; 9 | import org.springframework.web.bind.annotation.ExceptionHandler; 10 | import org.springframework.web.bind.annotation.ResponseStatus; 11 | import org.springframework.web.bind.annotation.RestController; 12 | import org.springframework.web.bind.annotation.RestControllerAdvice; 13 | 14 | @RestControllerAdvice(annotations = RestController.class) 15 | @Slf4j 16 | public class CommonControllerAdvice { 17 | 18 | @ResponseStatus(value = HttpStatus.BAD_REQUEST) 19 | @ExceptionHandler({IllegalArgumentException.class}) 20 | public ApiException handleBadRequest(IllegalArgumentException e) { 21 | ApiException exception = ApiException.of(ApiResponseCode.BAD_PARAMETER, e.getMessage()); 22 | log.error("code: {}, message: {}", exception.getCode(), e.getMessage()); 23 | return exception; 24 | } 25 | 26 | @ResponseStatus(value = HttpStatus.BAD_REQUEST) 27 | @ExceptionHandler(NumberFormatException.class) 28 | public ApiException handleNumberFormat(NumberFormatException e) { 29 | ApiException exception = ApiException.of(ApiResponseCode.BAD_REQUEST); 30 | log.error("code: {}, message: {}", exception.getCode(), e.getMessage()); 31 | return exception; 32 | } 33 | 34 | @ResponseStatus(HttpStatus.BAD_REQUEST) 35 | @ExceptionHandler(BindException.class) 36 | public ApiException handleBinding(BindException e) { 37 | ErrorMessages errorMessages = new ErrorMessages(e.getBindingResult()); 38 | ApiException exception = ApiException.withInfo(ApiResponseCode.BAD_PARAMETER, errorMessages); 39 | log.error("code: {}, message: {}", exception.getCode(), e.getMessage()); 40 | return exception; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/service/MartService.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.service; 2 | 3 | import com.hongsi.martholidayalarm.api.dto.mart.LocationParameter; 4 | import com.hongsi.martholidayalarm.api.dto.mart.MartDto; 5 | import com.hongsi.martholidayalarm.api.dto.mart.MartTypeDto; 6 | import com.hongsi.martholidayalarm.api.exception.ResourceNotFoundException; 7 | import com.hongsi.martholidayalarm.api.repository.MartHolidayRepository; 8 | import com.hongsi.martholidayalarm.api.repository.MartLocationRepository; 9 | import com.hongsi.martholidayalarm.core.mart.Mart; 10 | import com.hongsi.martholidayalarm.core.mart.MartType; 11 | import lombok.RequiredArgsConstructor; 12 | import lombok.extern.slf4j.Slf4j; 13 | import org.springframework.data.domain.Sort; 14 | import org.springframework.stereotype.Service; 15 | import org.springframework.transaction.annotation.Transactional; 16 | 17 | import java.util.Collection; 18 | import java.util.List; 19 | 20 | @Service 21 | @RequiredArgsConstructor 22 | @Slf4j 23 | @Transactional(readOnly = true) 24 | public class MartService { 25 | 26 | private final MartHolidayRepository martHolidayRepository; 27 | private final MartLocationRepository martLocationRepository; 28 | 29 | public List findAll(Sort sort) { 30 | return martHolidayRepository.findAllOrderBy(sort); 31 | } 32 | 33 | public List findAllById(Collection ids, Sort sort) { 34 | return martHolidayRepository.findAllById(ids, sort); 35 | } 36 | 37 | public MartDto findById(Long id) { 38 | Mart mart = martHolidayRepository.findById(id) 39 | .orElseThrow(ResourceNotFoundException::new); 40 | return new MartDto(mart); 41 | } 42 | 43 | public List findAllByMartType(MartType martType, Sort sort) { 44 | return martHolidayRepository.findAllByMartType(martType, sort); 45 | } 46 | 47 | public List findAllByLocation(LocationParameter parameter) { 48 | return martLocationRepository.findAllByLocation(parameter.getLatitude(), parameter.getLongitude(), parameter.getDistance()); 49 | } 50 | 51 | public List findAllMartTypes() { 52 | return martHolidayRepository.findAllMartTypes(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/groovy/com/hongsi/martholidayalarm/api/repository/MartLocationRepositoryTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.repository 2 | 3 | import com.hongsi.martholidayalarm.core.holiday.Holiday 4 | import com.hongsi.martholidayalarm.core.location.Location 5 | import com.hongsi.martholidayalarm.core.mart.Mart 6 | import com.hongsi.martholidayalarm.core.mart.MartType 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest 9 | import org.springframework.context.annotation.Import 10 | import spock.lang.Specification 11 | 12 | import javax.persistence.EntityManager 13 | import javax.persistence.PersistenceContext 14 | import java.time.LocalDate 15 | 16 | import static java.time.LocalDate.now 17 | 18 | @DataJpaTest 19 | @Import(MartLocationRepository.class) 20 | class MartLocationRepositoryTest extends Specification { 21 | 22 | @PersistenceContext 23 | private EntityManager em 24 | 25 | @Autowired 26 | private MartLocationRepository martLocationRepository 27 | 28 | def "위경도로 마트 다건 조회"() { 29 | given: 30 | def target = Mart.builder() 31 | .martType(MartType.EMART) 32 | .realId("1") 33 | .region("서울") 34 | .branchName("신도림점") 35 | .holidays(createHolidays(now().minusDays(1), now(), now().plusDays(1))) 36 | .location(Location.of(37.507631, 126.890203)) 37 | .build() 38 | def nonTarget = Mart.builder() 39 | .martType(MartType.EMART) 40 | .realId("2") 41 | .region("서울") 42 | .branchName("성수점") 43 | .holidays(createHolidays(now())) 44 | .location(Location.of(37.539993, 127.053111)) 45 | .build() 46 | em.persist(nonTarget) 47 | em.persist(target) 48 | 49 | when: 50 | def marts = martLocationRepository.findAllByLocation(37.506872, 126.867378, 3) 51 | 52 | then: 53 | marts.size() == 1 54 | marts[0].branchName == target.branchName 55 | } 56 | 57 | private static Set createHolidays(LocalDate... dates) { 58 | dates.collect { Holiday.of(it) }.toSet() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/service/MartPushAsyncService.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.service; 2 | 3 | import com.hongsi.martholidayalarm.clients.firebase.exception.PushException; 4 | import com.hongsi.martholidayalarm.clients.firebase.message.FirebaseMessageSender; 5 | import com.hongsi.martholidayalarm.push.model.MartPushMessage; 6 | import com.hongsi.martholidayalarm.push.model.PushMart; 7 | import com.hongsi.martholidayalarm.push.model.PushUser; 8 | import com.hongsi.martholidayalarm.push.repository.PushMartRepository; 9 | import com.hongsi.martholidayalarm.push.repository.PushUserRepository; 10 | import lombok.RequiredArgsConstructor; 11 | import lombok.extern.slf4j.Slf4j; 12 | import org.springframework.scheduling.annotation.Async; 13 | import org.springframework.stereotype.Service; 14 | 15 | import java.time.LocalDate; 16 | import java.util.List; 17 | import java.util.function.Consumer; 18 | 19 | @Service 20 | @RequiredArgsConstructor 21 | @Slf4j 22 | public class MartPushAsyncService { 23 | 24 | private final PushMartRepository pushMartRepository; 25 | private final PushUserRepository pushUserRepository; 26 | private final FirebaseMessageSender sender; 27 | 28 | @Async("pushThreadPool") 29 | public void push(PushUser pushUser) { 30 | List pushMarts = pushMartRepository.findAllByIdInAndHolidayDate(pushUser.getFavorites(), getTomorrowDate()); 31 | String deviceToken = pushUser.getDeviceToken(); 32 | pushMarts.stream() 33 | .parallel() 34 | .map(pushMart -> MartPushMessage.of(deviceToken, pushMart)) 35 | .peek(it -> log.info("push to user. message: {}", it)) 36 | .forEach(send(deviceToken)); 37 | } 38 | 39 | private LocalDate getTomorrowDate() { 40 | return LocalDate.now().plusDays(1); 41 | } 42 | 43 | private Consumer send(String deviceToken) { 44 | return martPushMessage -> { 45 | try { 46 | sender.send(martPushMessage.toFirebaseMessage()); 47 | } catch (PushException e) { 48 | if (e.isDeletedToken()) { 49 | pushUserRepository.delete(deviceToken); 50 | } 51 | throw e; 52 | } 53 | }; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-slack/src/test/java/com/hongsi/martholidayalarm/client/slack/SlackNotifierTest.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.slack; 2 | 3 | import com.hongsi.martholidayalarm.client.slack.model.Color; 4 | import com.hongsi.martholidayalarm.client.slack.model.Emoji; 5 | import com.hongsi.martholidayalarm.client.slack.model.SlackChannel; 6 | import com.hongsi.martholidayalarm.client.slack.model.SlackMessage; 7 | import org.junit.jupiter.api.Disabled; 8 | import org.junit.jupiter.api.Test; 9 | import org.springframework.web.client.RestTemplate; 10 | 11 | import java.util.Arrays; 12 | import java.util.Collections; 13 | 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | 16 | class SlackNotifierTest { 17 | 18 | private final SlackNotifier slackNotifier = new SlackNotifier(new RestTemplate()); 19 | 20 | @Disabled 21 | @Test 22 | public void testNotify() { 23 | SlackMessage slack = SlackMessage.builder() 24 | .username("장애 알림") 25 | .iconEmoji(Emoji.FIRE) 26 | .channel(SlackChannel.CRAWLING_ALARM) 27 | .attachments(Collections.singletonList( 28 | SlackMessage.Attachment.builder() 29 | .text("이마트 크롤링 실패") 30 | .color(Color.RED) 31 | .fields(Arrays.asList( 32 | SlackMessage.Attachment.Field.builder() 33 | .title("Error") 34 | .value("NotFoundException") 35 | .shortField(true) 36 | .build(), 37 | SlackMessage.Attachment.Field.builder() 38 | .title("StackTrace") 39 | .value("Stack") 40 | .shortField(false) 41 | .build() 42 | )) 43 | .build() 44 | )) 45 | .build(); 46 | 47 | boolean success = slackNotifier.notify(slack); 48 | 49 | assertThat(success).isTrue(); 50 | } 51 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/controller/advice/MartControllerAdvice.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller.advice; 2 | 3 | import com.hongsi.martholidayalarm.api.controller.MartController; 4 | import com.hongsi.martholidayalarm.api.dto.ApiException; 5 | import com.hongsi.martholidayalarm.api.dto.ApiResponseCode; 6 | import com.hongsi.martholidayalarm.api.exception.InvalidMartTypeException; 7 | import com.hongsi.martholidayalarm.api.exception.ResourceNotFoundException; 8 | import com.hongsi.martholidayalarm.core.exception.LocationOutOfRangeException; 9 | import lombok.extern.slf4j.Slf4j; 10 | import org.springframework.core.Ordered; 11 | import org.springframework.core.annotation.Order; 12 | import org.springframework.http.HttpStatus; 13 | import org.springframework.web.bind.annotation.ExceptionHandler; 14 | import org.springframework.web.bind.annotation.ResponseStatus; 15 | import org.springframework.web.bind.annotation.RestControllerAdvice; 16 | 17 | @RestControllerAdvice(basePackageClasses = MartController.class) 18 | @Order(Ordered.HIGHEST_PRECEDENCE) 19 | @Slf4j 20 | public class MartControllerAdvice { 21 | 22 | @ResponseStatus(value = HttpStatus.NOT_FOUND) 23 | @ExceptionHandler(ResourceNotFoundException.class) 24 | public ApiException handleResourceNotFound(ResourceNotFoundException e) { 25 | ApiException exception = ApiException.of(ApiResponseCode.NOT_FOUND); 26 | log.error("code: {}, message: {}", exception.getCode(), e.getMessage()); 27 | return exception; 28 | } 29 | 30 | @ResponseStatus(value = HttpStatus.BAD_REQUEST) 31 | @ExceptionHandler(InvalidMartTypeException.class) 32 | public ApiException handleInvalidMartType(InvalidMartTypeException e) { 33 | ApiException exception = ApiException.withInfo(ApiResponseCode.BAD_REQUEST, e.getMessage(), e.getPossibleMartTypes()); 34 | log.error("code: {}, message: {}", exception.getCode(), exception.getMessage()); 35 | return exception; 36 | } 37 | 38 | @ResponseStatus(value = HttpStatus.BAD_REQUEST) 39 | @ExceptionHandler(LocationOutOfRangeException.class) 40 | public ApiException handleLocationException(LocationOutOfRangeException e) { 41 | ApiException exception = ApiException.of(ApiResponseCode.BAD_PARAMETER, e.getMessage()); 42 | log.error("code: {}, message: {}", exception.getCode(), exception.getMessage()); 43 | return exception; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/MartCrawlerService.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler; 2 | 3 | import com.hongsi.martholidayalarm.client.location.converter.LocationConversion; 4 | import com.hongsi.martholidayalarm.client.location.converter.LocationConverter; 5 | import com.hongsi.martholidayalarm.core.mart.Mart; 6 | import com.hongsi.martholidayalarm.core.mart.MartRepository; 7 | import com.hongsi.martholidayalarm.crawler.domain.InvalidCrawledMart; 8 | import com.hongsi.martholidayalarm.crawler.domain.InvalidCrawledMartRepository; 9 | import com.hongsi.martholidayalarm.crawler.model.CrawledMart; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.stereotype.Service; 12 | import org.springframework.transaction.annotation.Transactional; 13 | 14 | import java.util.List; 15 | import java.util.stream.Collectors; 16 | 17 | @Service 18 | @RequiredArgsConstructor 19 | public class MartCrawlerService { 20 | 21 | private final MartRepository martRepository; 22 | private final InvalidCrawledMartRepository invalidCrawledMartRepository; 23 | private final LocationConverter locationConverter; 24 | 25 | @Transactional 26 | public List saveCrawledMarts(List crawledMarts) { 27 | List invalidCrawledMarts = invalidCrawledMartRepository.findAllByEnable(true); 28 | return crawledMarts.stream() 29 | .filter(crawledMart -> crawledMart.canCrawl(invalidCrawledMarts)) 30 | .peek(this::convertLocationIfEmpty) 31 | .map(CrawledMart::toEntity) 32 | .peek(this::save) 33 | .collect(Collectors.toList()); 34 | } 35 | 36 | private Mart save(Mart mart) { 37 | return martRepository.findByRealIdAndMartType(mart.getRealId(), mart.getMartType()) 38 | .map(savedMart -> savedMart.update(mart)) 39 | .orElseGet(() -> martRepository.save(mart)); 40 | } 41 | 42 | private void convertLocationIfEmpty(CrawledMart crawledMart) { 43 | if (crawledMart.hasLocation()) { 44 | return; 45 | } 46 | 47 | LocationConversion conversion = locationConverter.convert( 48 | () -> String.format("%s %s", crawledMart.getMartType().getName(), crawledMart.getBranchName()), 49 | crawledMart::getAddress 50 | ); 51 | crawledMart.setLocation(conversion.getLatitude(), conversion.getLongitude()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/lottemart/LotteMartCrawler.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.lottemart; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.hongsi.martholidayalarm.crawler.MartCrawler; 5 | import com.hongsi.martholidayalarm.crawler.MartCrawlerType; 6 | import com.hongsi.martholidayalarm.crawler.model.MartParser; 7 | import com.hongsi.martholidayalarm.crawler.utils.HtmlParser; 8 | import com.hongsi.martholidayalarm.crawler.utils.JsonParser; 9 | import org.jsoup.nodes.Element; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.util.List; 13 | import java.util.Objects; 14 | 15 | import static java.util.stream.Collectors.toList; 16 | 17 | @Component 18 | public class LotteMartCrawler implements MartCrawler { 19 | 20 | private static final String REGION_URL = MartCrawlerType.LOTTEMART.appendUrl("/bc/branch/holidaystore.do?SITELOC=DC009"); 21 | private static final String REAL_ID_URL = MartCrawlerType.LOTTEMART.appendUrl("/bc/branch/regnstorelist.json?regionCode="); 22 | private static final String DATA_URL = MartCrawlerType.LOTTEMART.appendUrl("/bc/branch/storeinfo.json?brnchCd="); 23 | private static final String REGION_BUTTON_SELECTOR = "div.wrap-location-inner > button"; 24 | private static final String REAL_ID_KEY = "brnchCd"; 25 | private static final String DATA_KEY = "data"; 26 | 27 | @Override 28 | public List crawl() { 29 | return parseRegionCode().stream() 30 | .flatMap(regionCode -> parseRealId(regionCode).stream()) 31 | .distinct() 32 | .map(this::parseData) 33 | .filter(Objects::nonNull) 34 | .collect(toList()); 35 | } 36 | 37 | private List parseRegionCode() { 38 | return HtmlParser.get(REGION_URL) 39 | .select(REGION_BUTTON_SELECTOR).stream() 40 | .map(Element::val) 41 | .collect(toList()); 42 | } 43 | 44 | private List parseRealId(String regionCode) { 45 | String url = REAL_ID_URL + regionCode; 46 | JsonNode dataNode = JsonParser.request(url).get(DATA_KEY); 47 | return dataNode.findValuesAsText(REAL_ID_KEY); 48 | } 49 | 50 | private LotteMartParser parseData(String realId) { 51 | String url = DATA_URL + realId; 52 | JsonNode dataNode = JsonParser.request(url).get(DATA_KEY); 53 | return JsonParser.convert(dataNode, LotteMartParser.class); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/main/java/com/hongsi/martholidayalarm/core/location/Location.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core.location; 2 | 3 | import com.hongsi.martholidayalarm.core.exception.LocationOutOfRangeException; 4 | import lombok.Builder; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.ToString; 8 | 9 | import javax.persistence.Column; 10 | import javax.persistence.Embeddable; 11 | import java.util.Objects; 12 | 13 | @Embeddable 14 | @NoArgsConstructor 15 | @Getter 16 | @ToString 17 | public class Location { 18 | 19 | @Column(columnDefinition = "DECIMAL(10, 8)") 20 | private Double latitude; 21 | 22 | @Column(columnDefinition = "DECIMAL(11, 8)") 23 | private Double longitude; 24 | 25 | @Builder 26 | private Location(Double latitude, Double longitude) { 27 | Range.Latitude.validate(latitude); 28 | Range.Longitude.validate(longitude); 29 | 30 | this.latitude = latitude; 31 | this.longitude = longitude; 32 | } 33 | 34 | public static Location parse(String rawLatitude, String rawLongitude) { 35 | return Location.of(Double.valueOf(rawLatitude), Double.valueOf(rawLongitude)); 36 | } 37 | 38 | public static Location of(Double latitude, Double longitude) { 39 | return Location.builder() 40 | .latitude(latitude) 41 | .longitude(longitude) 42 | .build(); 43 | } 44 | 45 | @Getter 46 | public enum Range { 47 | 48 | Latitude("위도", -90, 90), 49 | Longitude("경도", -180, 180); 50 | 51 | private final String name; 52 | private final Double min; 53 | private final Double max; 54 | 55 | Range(String name, int min, int max) { 56 | this.name = name; 57 | this.min = (double) min; 58 | this.max = (double) max; 59 | } 60 | 61 | public void validate(Double point) { 62 | if (Objects.isNull(point)) { 63 | throw new IllegalArgumentException("유효하지 않은 좌표입니다."); 64 | } 65 | if (!isValid(point)) { 66 | throw new LocationOutOfRangeException(this); 67 | } 68 | } 69 | 70 | public boolean isValid(Double point) { 71 | return !isOutOfMinRange(point) && !isOutOfMaxRange(point); 72 | } 73 | 74 | private boolean isOutOfMinRange(Double point) { 75 | return min > point; 76 | } 77 | 78 | private boolean isOutOfMaxRange(Double point) { 79 | return max < point; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/java/com/hongsi/martholidayalarm/api/docs/CommonDocumentControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.docs; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 7 | import org.springframework.boot.test.mock.mockito.MockBean; 8 | import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; 9 | import org.springframework.http.MediaType; 10 | import org.springframework.restdocs.payload.FieldDescriptor; 11 | import org.springframework.restdocs.payload.PayloadSubsectionExtractor; 12 | import org.springframework.test.context.junit4.SpringRunner; 13 | import org.springframework.test.web.servlet.MockMvc; 14 | import org.springframework.test.web.servlet.ResultActions; 15 | 16 | import java.util.Arrays; 17 | import java.util.Map; 18 | 19 | import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; 20 | import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; 21 | import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; 22 | import static org.springframework.restdocs.snippet.Attributes.attributes; 23 | import static org.springframework.restdocs.snippet.Attributes.key; 24 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 25 | 26 | @RunWith(SpringRunner.class) 27 | @WebMvcTest(CommonDocumentController.class) 28 | @MockBean(JpaMetamodelMappingContext.class) 29 | public class CommonDocumentControllerTest extends CommonApiDocumentConfigure { 30 | 31 | @Autowired 32 | private MockMvc mockMvc; 33 | 34 | @Test 35 | public void commonResponse() throws Exception { 36 | ResultActions result = mockMvc.perform( 37 | get("/docs") 38 | .contentType(MediaType.APPLICATION_JSON) 39 | ); 40 | 41 | result.andExpect(status().isOk()) 42 | .andDo(document("common", 43 | commonResponseFields("common-response", null, 44 | attributes(key("title").value("공통 응답")), 45 | subsectionWithPath("code").description("응답 코드"), 46 | subsectionWithPath("message").description("응답 메세지"), 47 | subsectionWithPath("data").description("데이터") 48 | ) 49 | )); 50 | } 51 | 52 | public static CommonResponseFieldsSnippet commonResponseFields( 53 | String type, PayloadSubsectionExtractor subsectionExtractor, 54 | Map attributes, FieldDescriptor... descriptors) { 55 | return new CommonResponseFieldsSnippet(type,subsectionExtractor, 56 | Arrays.asList(descriptors), attributes, true); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/holiday/MonthDayHoliday.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday; 2 | 3 | import com.hongsi.martholidayalarm.core.holiday.Holiday; 4 | import lombok.extern.slf4j.Slf4j; 5 | 6 | import java.time.Month; 7 | import java.time.MonthDay; 8 | import java.time.YearMonth; 9 | import java.time.format.DateTimeFormatter; 10 | import java.time.format.DateTimeParseException; 11 | import java.util.List; 12 | import java.util.Objects; 13 | 14 | import static java.util.Arrays.asList; 15 | 16 | @Slf4j 17 | public class MonthDayHoliday { 18 | 19 | private static final List NEXT_YEAR_CONDITION = asList(Month.JANUARY, Month.FEBRUARY); 20 | 21 | private final Month month; 22 | private final int dayOfMonth; 23 | 24 | public MonthDayHoliday(Month month, int dayOfMonth) { 25 | this.month = month; 26 | this.dayOfMonth = dayOfMonth; 27 | } 28 | 29 | public static MonthDayHoliday parse(String text, DateTimeFormatter dateTimeFormatter) { 30 | try { 31 | MonthDay monthDay = MonthDay.parse(text, dateTimeFormatter); 32 | return new MonthDayHoliday(monthDay.getMonth(), monthDay.getDayOfMonth()); 33 | } catch (NullPointerException | DateTimeParseException e) { 34 | log.error("can't parse month, day. text : {}, formatter : {}, message : {}", text, dateTimeFormatter, e.getMessage()); 35 | throw new IllegalArgumentException("날짜를 파싱할 수 없습니다."); 36 | } 37 | } 38 | 39 | /** 40 | * 마트 휴무일은 1-2개월마다 업데이트되므로 11, 12월에 휴무일 수집 시 1, 2월이면 내년 연도로 설정 41 | */ 42 | public int getYearFromMonth(YearMonth currentYearMonth) { 43 | Month currentMonth = currentYearMonth.getMonth(); 44 | if (currentMonth.getValue() < Month.NOVEMBER.getValue()) { 45 | return currentYearMonth.getYear(); 46 | } 47 | return (isMonthOfNextYear()) ? currentYearMonth.getYear() + 1 : currentYearMonth.getYear(); 48 | } 49 | 50 | private boolean isMonthOfNextYear() { 51 | return NEXT_YEAR_CONDITION.contains(month); 52 | } 53 | 54 | public Holiday toHoliday() { 55 | return Holiday.of(getYearFromMonth(YearMonth.now()), month.getValue(), dayOfMonth); 56 | } 57 | 58 | @Override 59 | public boolean equals(Object o) { 60 | if (this == o) return true; 61 | if (o == null || getClass() != o.getClass()) return false; 62 | MonthDayHoliday that = (MonthDayHoliday) o; 63 | return dayOfMonth == that.dayOfMonth && 64 | month == that.month; 65 | } 66 | 67 | @Override 68 | public int hashCode() { 69 | return Objects.hash(month, dayOfMonth); 70 | } 71 | 72 | @Override 73 | public String toString() { 74 | return "MonthDayHoliday{" + 75 | "month=" + month + 76 | ", dayOfMonth=" + dayOfMonth + 77 | '}'; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths-ignore: 7 | - '**.md' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: 8398a7/action-slack@v3 19 | name: Job start notification 20 | if: ${{ success() }} 21 | with: 22 | status: custom 23 | author_name: '빌드 알림' 24 | fields: repo,commit,message,action,ref,job,took,workflow,eventName 25 | custom_payload: | 26 | { text: `⏱ Started ${process.env.AS_EVENT_NAME} action : ${process.env.AS_JOB} of ${process.env.AS_REPO}` } 27 | 28 | - name: Set up JDK 1.8 29 | uses: actions/setup-java@v1 30 | with: 31 | java-version: 1.8 32 | - name: Grant execute permission for gradlew 33 | run: chmod +x gradlew 34 | - name: Build with Gradle 35 | run: ./gradlew build test 36 | 37 | - name: Job result notification 38 | uses: 8398a7/action-slack@v3 39 | if: always() 40 | with: 41 | status: custom 42 | author_name: '빌드 알림' 43 | fields: repo,commit,message,action,ref,job,took,workflow,eventName 44 | custom_payload: | 45 | { 46 | text: ('${{ job.status }}' === 'success' ? "🎉 Successd" : '${{ job.status }}' === 'failure' ? "☄️ Failed" : "✖️ Cancelled") + ` ${process.env.AS_EVENT_NAME} action : ${process.env.AS_JOB} of ${process.env.AS_REPO}`, 47 | attachments: [ 48 | { 49 | color: '${{ job.status }}' === 'success' ? "#00FF00" : '${{ job.status }}' === 'failure' ? "#FF0000" : "#505050", 50 | blocks: [ 51 | { 52 | type: "section", 53 | fields: [ 54 | { type: "mrkdwn", text: `*repo:*\n${process.env.AS_REPO}` }, 55 | { type: "mrkdwn", text: `*branch:*\n${process.env.AS_REF}` }, 56 | { type: "mrkdwn", text: `*commit:*\n${process.env.AS_MESSAGE} (${process.env.AS_COMMIT})` }, 57 | { type: "mrkdwn", text: `*job:*\n${process.env.AS_WORKFLOW} -> ${process.env.AS_JOB}` }, 58 | { type: "mrkdwn", text: `*job event:*\n${process.env.AS_EVENT_NAME}` }, 59 | { type: "mrkdwn", text: `*elasped time:*\n${process.env.AS_TOOK}` }, 60 | ] 61 | } 62 | ] 63 | } 64 | ] 65 | } -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/main/java/com/hongsi/martholidayalarm/crawler/model/holiday/RegularHolidayGenerator.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday; 2 | 3 | import com.hongsi.martholidayalarm.core.holiday.Holiday; 4 | import org.springframework.util.StringUtils; 5 | 6 | import java.time.LocalDate; 7 | import java.util.List; 8 | import java.util.Objects; 9 | import java.util.stream.Collectors; 10 | 11 | public class RegularHolidayGenerator { 12 | 13 | private final List regularHolidays; 14 | 15 | private RegularHolidayGenerator(List regularHolidays) { 16 | this.regularHolidays = regularHolidays; 17 | } 18 | 19 | public static RegularHolidayGenerator from(String holidayText) { 20 | if (!StringUtils.hasText(holidayText)) { 21 | throw new IllegalArgumentException("Not exists holiday text"); 22 | } 23 | List weeks = KoreanWeek.parseToCollection(holidayText); 24 | List dayOfWeeks = KoreanDayOfWeek.parseToCollection(holidayText); 25 | return of(createRegularHolidays(weeks, dayOfWeeks)); 26 | } 27 | 28 | private static List createRegularHolidays(List weeks, List dayOfWeeks) { 29 | return weeks.stream() 30 | .flatMap(week -> dayOfWeeks.stream() 31 | .map(dayOfWeek -> RegularHoliday.of(week, dayOfWeek)) 32 | ) 33 | .collect(Collectors.toList()); 34 | } 35 | 36 | public static RegularHolidayGenerator of(List regularHolidays) { 37 | if (regularHolidays == null || regularHolidays.isEmpty()) { 38 | throw new IllegalArgumentException("Not found regular holiday"); 39 | } 40 | return new RegularHolidayGenerator(regularHolidays); 41 | } 42 | 43 | public List generate(LocalDate startDate) { 44 | RegularHolidayParser regularHolidayParser = RegularHolidayParser.from(startDate); 45 | return regularHolidays.stream() 46 | .map(regularHolidayParser::parse) 47 | .flatMap(List::stream) 48 | .map(Holiday::of) 49 | .sorted() 50 | .collect(Collectors.toList()); 51 | } 52 | 53 | @Override 54 | public boolean equals(Object o) { 55 | if (this == o) return true; 56 | if (o == null || getClass() != o.getClass()) return false; 57 | RegularHolidayGenerator that = (RegularHolidayGenerator) o; 58 | return Objects.equals(regularHolidays, that.regularHolidays); 59 | } 60 | 61 | @Override 62 | public int hashCode() { 63 | return Objects.hash(regularHolidays); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/holiday/LocalDateRangeTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday 2 | 3 | import com.hongsi.martholidayalarm.crawler.model.holiday.LocalDateRange 4 | import spock.lang.Specification 5 | 6 | import java.time.LocalDate 7 | 8 | class LocalDateRangeTest extends Specification { 9 | 10 | def "Should be before end date"() { 11 | given: 12 | 13 | when: 14 | LocalDateRange.of(startDate, endDate) 15 | 16 | then: 17 | thrown(IllegalArgumentException) 18 | 19 | where: 20 | startDate | endDate 21 | LocalDate.of(2019, 10, 9) | null 22 | null | LocalDate.of(2019, 10, 9) 23 | null | null 24 | LocalDate.of(2019, 10, 9) | startDate.minusDays(1) 25 | 26 | } 27 | 28 | def "Should be between range"() { 29 | given: 30 | def localDateRange = LocalDateRange.of(start, end) 31 | 32 | when: 33 | def actual = localDateRange.isBetween(date) 34 | 35 | then: 36 | actual == expected 37 | 38 | where: 39 | start | end | date || expected 40 | LocalDate.of(2019, 10, 1) | LocalDate.of(2019, 10, 9) | start || true 41 | LocalDate.of(2019, 10, 1) | LocalDate.of(2019, 10, 9) | start.plusDays(1) || true 42 | LocalDate.of(2019, 10, 1) | LocalDate.of(2019, 10, 9) | end || true 43 | LocalDate.of(2019, 10, 1) | LocalDate.of(2019, 10, 9) | end.minusDays(1) || true 44 | 45 | LocalDate.of(2019, 10, 1) | LocalDate.of(2019, 10, 9) | start.minusDays(1) || false 46 | LocalDate.of(2019, 10, 1) | LocalDate.of(2019, 10, 9) | end.plusDays(1) || false 47 | } 48 | 49 | def "Should be between range with days"() { 50 | given: 51 | def localDateRange = LocalDateRange.withDays(start, days) 52 | 53 | when: 54 | def actual = localDateRange.isBetween(date) 55 | 56 | then: 57 | actual == expected 58 | 59 | where: 60 | start | days | date || expected 61 | LocalDate.of(2019, 10, 1) | 8 | start || true 62 | LocalDate.of(2019, 10, 1) | 8 | start.plusDays(1) || true 63 | LocalDate.of(2019, 10, 1) | 8 | start.plusDays(days) || true 64 | LocalDate.of(2019, 10, 1) | 8 | start.plusDays(days - 1) || true 65 | 66 | LocalDate.of(2019, 10, 1) | 8 | start.minusDays(1) || false 67 | LocalDate.of(2019, 10, 1) | 8 | start.plusDays(days + 1) || false 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/test/java/com/hongsi/martholidayalarm/api/controller/MartDocumentDescriptor.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller; 2 | 3 | import org.springframework.restdocs.payload.FieldDescriptor; 4 | import org.springframework.restdocs.payload.JsonFieldType; 5 | import org.springframework.restdocs.request.ParameterDescriptor; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; 11 | import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; 12 | import static org.springframework.restdocs.snippet.Attributes.key; 13 | 14 | public interface MartDocumentDescriptor { 15 | 16 | static List getMartFieldDescriptors() { 17 | List fieldDescriptors = new ArrayList<>(); 18 | fieldDescriptors.add(fieldWithPath("id").type(JsonFieldType.NUMBER).description("아이디")); 19 | fieldDescriptors.add(fieldWithPath("createdDate").type(JsonFieldType.STRING).description("생성일시")); 20 | fieldDescriptors.add(fieldWithPath("modifiedDate").type(JsonFieldType.STRING).description("수정일시")); 21 | fieldDescriptors.add(fieldWithPath("martType").type(JsonFieldType.STRING).description("마트타입")); 22 | fieldDescriptors.add(fieldWithPath("branchName").type(JsonFieldType.STRING).description("지점명")); 23 | fieldDescriptors.add(fieldWithPath("region").type(JsonFieldType.STRING).description("지역")); 24 | fieldDescriptors.add(fieldWithPath("phoneNumber").type(JsonFieldType.STRING).description("전화번호")); 25 | fieldDescriptors.add(fieldWithPath("address").type(JsonFieldType.STRING).description("주소")); 26 | fieldDescriptors.add(fieldWithPath("openingHours").type(JsonFieldType.STRING).description("영업시간")); 27 | fieldDescriptors.add(fieldWithPath("url").type(JsonFieldType.STRING).description("홈페이지")); 28 | fieldDescriptors.add(fieldWithPath("holidays[]").type(JsonFieldType.ARRAY).description("휴일")); 29 | fieldDescriptors.add(fieldWithPath("location").type(JsonFieldType.OBJECT).description("좌표")); 30 | fieldDescriptors.add(fieldWithPath("location.latitude").type(JsonFieldType.NUMBER).description("위도")); 31 | fieldDescriptors.add(fieldWithPath("location.longitude").type(JsonFieldType.NUMBER).description("경도")); 32 | return fieldDescriptors; 33 | } 34 | 35 | static ParameterDescriptor getSortParameterDescriptor(String defaultSort) { 36 | return parameterWithName("sort").description("정렬 방법 - 정렬 방향을 생략하면 ASC로 적용 (다건의 경우 콤마(,)로 구분)").optional() 37 | .attributes(key("format").value("필드명[:ASC|DESC]")) 38 | .attributes(key("default").value(defaultSort)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/repository/MartLocationRepository.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.repository; 2 | 3 | import com.hongsi.martholidayalarm.api.dto.mart.MartDto; 4 | import com.hongsi.martholidayalarm.core.BaseQuerydslRepositorySupport; 5 | import com.hongsi.martholidayalarm.core.mart.Mart; 6 | import com.querydsl.core.types.Expression; 7 | import com.querydsl.core.types.Projections; 8 | import com.querydsl.core.types.dsl.Expressions; 9 | import com.querydsl.core.types.dsl.NumberExpression; 10 | import com.querydsl.core.types.dsl.NumberPath; 11 | import org.springframework.stereotype.Repository; 12 | 13 | import java.util.List; 14 | import java.util.stream.Collectors; 15 | 16 | import static com.hongsi.martholidayalarm.core.holiday.QHoliday.holiday; 17 | import static com.hongsi.martholidayalarm.core.mart.QMart.mart; 18 | import static com.querydsl.core.types.dsl.MathExpressions.*; 19 | 20 | @Repository 21 | public class MartLocationRepository extends BaseQuerydslRepositorySupport { 22 | 23 | public MartLocationRepository() { 24 | super(Mart.class); 25 | } 26 | 27 | public List findAllByLocation(Double latitude, Double longitude, Integer distance) { 28 | NumberExpression distanceFormula = distanceFormula(latitude, longitude); 29 | NumberPath distancePath = Expressions.numberPath(Double.class, "distance"); 30 | 31 | return select(Projections.constructor(MartDto.class, mart, distanceFormula.as(distancePath))) 32 | .from(mart) 33 | .leftJoin(mart.holidays, holiday).fetchJoin() 34 | .where(distanceFormula.loe(distance)) 35 | .orderBy(distancePath.asc()) 36 | .fetch() 37 | .stream() 38 | .distinct() 39 | .collect(Collectors.toList()); 40 | } 41 | 42 | private NumberExpression distanceFormula(Double latitude, Double longitude) { 43 | Expression currentLatitude = Expressions.constant(latitude); 44 | Expression currentLongitude = Expressions.constant(longitude); 45 | final Expression TO_KILOMETER = Expressions.constant(6371); 46 | return acos( 47 | cos(radians(currentLatitude)) 48 | .multiply(cos(radians(mart.location.latitude))) 49 | .multiply(cos(radians(mart.location.longitude) 50 | .subtract(radians(currentLongitude))) 51 | ) 52 | .add(sin(radians(currentLatitude)) 53 | .multiply(sin(radians(mart.location.latitude))) 54 | ) 55 | ).multiply(TO_KILOMETER); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /mart-holiday-alarm-crawler/src/test/groovy/com/hongsi/martholidayalarm/crawler/model/holiday/MonthDayHolidayTest.groovy: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.crawler.model.holiday 2 | 3 | import com.hongsi.martholidayalarm.core.holiday.Holiday 4 | import com.hongsi.martholidayalarm.crawler.model.holiday.MonthDayHoliday 5 | import spock.lang.Specification 6 | 7 | import java.time.Month 8 | import java.time.Year 9 | import java.time.YearMonth 10 | import java.time.format.DateTimeFormatter 11 | 12 | class MonthDayHolidayTest extends Specification { 13 | 14 | def "Should parse from text with formatter"() { 15 | given: 16 | def formatter = DateTimeFormatter.ofPattern("M/d") 17 | 18 | when: 19 | def actual = MonthDayHoliday.parse(text, formatter) 20 | 21 | then: 22 | actual == new MonthDayHoliday(Month.SEPTEMBER, 29) 23 | 24 | where: 25 | text | _ 26 | "9/29" | _ 27 | "09/29" | _ 28 | } 29 | 30 | def "Shouldn't parse from invalid text"() { 31 | given: 32 | def text = "9-29" 33 | def formatter = DateTimeFormatter.ofPattern("M/d") 34 | 35 | when: 36 | MonthDayHoliday.parse(text, formatter) 37 | 38 | then: 39 | thrown(IllegalArgumentException) 40 | } 41 | 42 | def "Should get current year if current month is before november"() { 43 | given: 44 | def monthDayHoliday = new MonthDayHoliday(holidayMonth, 1) 45 | def currentYearMonth = YearMonth.of(2018, currentMonth) 46 | 47 | when: 48 | def actual = monthDayHoliday.getYearFromMonth(currentYearMonth) 49 | 50 | then: 51 | actual == currentYearMonth.getYear() 52 | 53 | where: 54 | holidayMonth | currentMonth | _ 55 | Month.OCTOBER | Month.SEPTEMBER | _ 56 | Month.NOVEMBER | Month.SEPTEMBER | _ 57 | } 58 | 59 | def "Should get next year if current month is equal or after november and holiday is before february"() { 60 | given: 61 | def monthDayHoliday = new MonthDayHoliday(holidayMonth, 1) 62 | def currentYearMonth = YearMonth.of(2018, currentMonth) 63 | 64 | when: 65 | def actual = monthDayHoliday.getYearFromMonth(currentYearMonth) 66 | 67 | then: 68 | actual == currentYearMonth.getYear() + 1 69 | 70 | where: 71 | holidayMonth | currentMonth | _ 72 | Month.JANUARY | Month.NOVEMBER | _ 73 | Month.JANUARY | Month.DECEMBER | _ 74 | Month.FEBRUARY | Month.NOVEMBER | _ 75 | Month.FEBRUARY | Month.DECEMBER | _ 76 | } 77 | 78 | def "get holiday domain"() { 79 | given: 80 | def monthDayHoliday = new MonthDayHoliday(Month.SEPTEMBER, 29) 81 | def currentYear = Year.now().getValue() 82 | 83 | expect: 84 | monthDayHoliday.toHoliday() == Holiday.of(currentYear, 9, 29) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /mart-holiday-alarm-core/src/main/java/com/hongsi/martholidayalarm/core/BaseQuerydslRepositorySupport.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.core; 2 | 3 | import com.querydsl.core.types.EntityPath; 4 | import com.querydsl.core.types.Expression; 5 | import com.querydsl.core.types.dsl.PathBuilder; 6 | import com.querydsl.jpa.JPQLQuery; 7 | import com.querydsl.jpa.impl.JPAQuery; 8 | import com.querydsl.jpa.impl.JPAQueryFactory; 9 | import org.springframework.data.domain.Sort; 10 | import org.springframework.data.jpa.repository.support.JpaEntityInformation; 11 | import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport; 12 | import org.springframework.data.jpa.repository.support.Querydsl; 13 | import org.springframework.data.querydsl.SimpleEntityPathResolver; 14 | import org.springframework.util.Assert; 15 | 16 | import javax.annotation.PostConstruct; 17 | import javax.persistence.EntityManager; 18 | import javax.persistence.PersistenceContext; 19 | import java.util.function.Function; 20 | 21 | public abstract class BaseQuerydslRepositorySupport { 22 | 23 | private final Class domainClass; 24 | private Querydsl querydsl; 25 | private EntityManager entityManager; 26 | private JPAQueryFactory queryFactory; 27 | 28 | public BaseQuerydslRepositorySupport(Class domainClass) { 29 | Assert.notNull(domainClass, "Domain class must not be null!"); 30 | this.domainClass = domainClass; 31 | } 32 | 33 | @PersistenceContext 34 | public void setEntityManager(EntityManager entityManager) { 35 | Assert.notNull(entityManager, "EntityManager must not be null!"); 36 | 37 | JpaEntityInformation entityInformation = JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager); 38 | SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE; 39 | EntityPath path = resolver.createPath(entityInformation.getJavaType()); 40 | this.querydsl = new Querydsl(entityManager, new PathBuilder<>(path.getType(), path.getMetadata())); 41 | this.entityManager = entityManager; 42 | this.queryFactory = new JPAQueryFactory(entityManager); 43 | } 44 | 45 | @PostConstruct 46 | public void validate() { 47 | Assert.notNull(entityManager, "EntityManager must not be null!"); 48 | Assert.notNull(querydsl, "Querydsl must not be null!"); 49 | Assert.notNull(queryFactory, "JPAQueryFactory must not be null!"); 50 | } 51 | 52 | protected JPAQueryFactory getQueryFactory() { 53 | return queryFactory; 54 | } 55 | 56 | protected JPAQuery select(Expression expr) { 57 | return getQueryFactory().select(expr); 58 | } 59 | 60 | protected JPAQuery selectFrom(EntityPath from) { 61 | return getQueryFactory().selectFrom(from); 62 | } 63 | 64 | protected JPQLQuery applySorting(Sort sort, Function> query) { 65 | return querydsl.applySorting(sort, query.apply(getQueryFactory())); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/repository/MartHolidayRepositoryImpl.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.repository; 2 | 3 | import com.hongsi.martholidayalarm.api.dto.mart.MartDto; 4 | import com.hongsi.martholidayalarm.api.dto.mart.MartTypeDto; 5 | import com.hongsi.martholidayalarm.core.BaseQuerydslRepositorySupport; 6 | import com.hongsi.martholidayalarm.core.holiday.Holiday; 7 | import com.hongsi.martholidayalarm.core.mart.Mart; 8 | import com.hongsi.martholidayalarm.core.mart.MartType; 9 | import com.querydsl.core.types.Projections; 10 | import org.springframework.data.domain.Sort; 11 | import org.springframework.stereotype.Repository; 12 | 13 | import java.util.Collection; 14 | import java.util.List; 15 | 16 | import static com.hongsi.martholidayalarm.core.holiday.QHoliday.holiday; 17 | import static com.hongsi.martholidayalarm.core.mart.QMart.mart; 18 | 19 | @Repository 20 | public class MartHolidayRepositoryImpl extends BaseQuerydslRepositorySupport implements MartHolidayRepositoryCustom { 21 | 22 | public MartHolidayRepositoryImpl() { 23 | super(Mart.class); 24 | } 25 | 26 | @Override 27 | public List findAllOrderBy(Sort sort) { 28 | return applySorting(sort, query -> query 29 | .select(Projections.constructor(MartDto.class, mart)) 30 | .from(mart) 31 | .distinct() 32 | .leftJoin(mart.holidays, holiday).fetchJoin()) 33 | .fetch(); 34 | } 35 | 36 | @Override 37 | public List findAllById(Collection ids, Sort sort) { 38 | return applySorting(sort, query -> query 39 | .select(Projections.constructor(MartDto.class, mart)) 40 | .from(mart) 41 | .distinct() 42 | .leftJoin(mart.holidays, holiday).fetchJoin() 43 | .where(mart.id.in(ids))) 44 | .fetch(); 45 | } 46 | 47 | @Override 48 | public List findAllByHoliday(Holiday condition) { 49 | return select(Projections.constructor(MartDto.class, mart)) 50 | .from(mart) 51 | .distinct() 52 | .innerJoin(mart.holidays, holiday).fetchJoin() 53 | .where(holiday.date.eq(condition.getDate())) 54 | .fetch(); 55 | } 56 | 57 | @Override 58 | public List findAllByMartType(MartType martType, Sort sort) { 59 | return applySorting(sort, query -> query 60 | .select(Projections.constructor(MartDto.class, mart)) 61 | .from(mart) 62 | .distinct() 63 | .leftJoin(mart.holidays, holiday).fetchJoin() 64 | .where(mart.martType.eq(martType))) 65 | .fetch(); 66 | } 67 | 68 | @Override 69 | public List findAllMartTypes() { 70 | return select(Projections.constructor(MartTypeDto.class, mart.martType)) 71 | .from(mart) 72 | .groupBy(mart.martType) 73 | .orderBy(mart.martType.asc()) 74 | .fetch(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/dto/mart/MartDto.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.dto.mart; 2 | 3 | import com.hongsi.martholidayalarm.core.mart.Mart; 4 | import lombok.AccessLevel; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.time.LocalDateTime; 9 | import java.time.format.DateTimeFormatter; 10 | import java.util.List; 11 | import java.util.Locale; 12 | import java.util.stream.Collectors; 13 | 14 | @Data 15 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 16 | public class MartDto { 17 | 18 | private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter 19 | .ofPattern("yyyy-MM-dd HH:mm:ss").withLocale(Locale.KOREAN); 20 | private static final DateTimeFormatter HOLIDAY_FORMATTER = DateTimeFormatter 21 | .ofPattern("yyyy-MM-dd (EE)").withLocale(Locale.KOREAN); 22 | 23 | private Long id; 24 | private String createdDate; 25 | private String modifiedDate; 26 | private String martType; 27 | private String branchName; 28 | private String region; 29 | private String phoneNumber; 30 | private String address; 31 | private String openingHours; 32 | private String url; 33 | private List holidays; 34 | private LocationDto location; 35 | 36 | public MartDto(Mart mart) { 37 | this.id = mart.getId(); 38 | this.createdDate = formatDate(mart.getCreatedDate()); 39 | this.modifiedDate = formatDate(mart.getModifiedDate()); 40 | this.martType = mart.getMartType().getName(); 41 | this.branchName = mart.getBranchName(); 42 | this.region = mart.getRegion(); 43 | this.phoneNumber = mart.getPhoneNumber(); 44 | this.address = mart.getAddress(); 45 | this.openingHours = mart.getOpeningHours(); 46 | this.url = mart.getUrl(); 47 | this.holidays = formatHolidays(mart); 48 | this.location = new LocationDto(mart.getLocation(), null); 49 | } 50 | 51 | public MartDto(Mart mart, Double distance) { 52 | this.id = mart.getId(); 53 | this.createdDate = formatDate(mart.getCreatedDate()); 54 | this.modifiedDate = formatDate(mart.getModifiedDate()); 55 | this.martType = mart.getMartType().getName(); 56 | this.branchName = mart.getBranchName(); 57 | this.region = mart.getRegion(); 58 | this.phoneNumber = mart.getPhoneNumber(); 59 | this.address = mart.getAddress(); 60 | this.openingHours = mart.getOpeningHours(); 61 | this.url = mart.getUrl(); 62 | this.holidays = formatHolidays(mart); 63 | this.location = new LocationDto(mart.getLocation(), distance); 64 | } 65 | 66 | private List formatHolidays(Mart mart) { 67 | return mart.getUpcomingHolidays() 68 | .stream() 69 | .map(holiday -> holiday.getFormattedHoliday(HOLIDAY_FORMATTER)) 70 | .collect(Collectors.toList()); 71 | } 72 | 73 | private String formatDate(LocalDateTime localDateTime) { 74 | if (localDateTime == null) { 75 | localDateTime = LocalDateTime.now(); 76 | } 77 | return localDateTime.format(DATE_TIME_FORMATTER); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-slack/src/main/java/com/hongsi/martholidayalarm/client/slack/model/SlackMessage.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.slack.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import com.fasterxml.jackson.databind.PropertyNamingStrategy; 6 | import com.fasterxml.jackson.databind.annotation.JsonNaming; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | import java.util.Arrays; 13 | import java.util.List; 14 | 15 | @Builder 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | @Data 19 | @JsonInclude(JsonInclude.Include.NON_NULL) 20 | @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) 21 | public class SlackMessage { 22 | 23 | private String username; 24 | private Emoji iconEmoji; 25 | private SlackChannel channel; 26 | private String text; 27 | private List attachments; 28 | 29 | public static SlackMessage success(String title, Attachment... attachments) { 30 | return SlackMessage.builder() 31 | .username(title) 32 | .text(title) 33 | .iconEmoji(Emoji.SUNNY) 34 | .channel(SlackChannel.CRAWLING_ALARM) 35 | .attachments(Arrays.asList(attachments)) 36 | .build(); 37 | } 38 | 39 | public static SlackMessage error(Attachment... attachments) { 40 | return SlackMessage.builder() 41 | .username("장애 알림") 42 | .iconEmoji(Emoji.FIRE) 43 | .channel(SlackChannel.CRAWLING_ALARM) 44 | .attachments(Arrays.asList(attachments)) 45 | .build(); 46 | } 47 | 48 | @Builder 49 | @AllArgsConstructor 50 | @NoArgsConstructor 51 | @Data 52 | @JsonInclude(JsonInclude.Include.NON_NULL) 53 | @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) 54 | public static class Attachment { 55 | 56 | private String text; 57 | private Color color; 58 | private List fields; 59 | 60 | @Builder 61 | @AllArgsConstructor 62 | @NoArgsConstructor 63 | @Data 64 | @JsonInclude(JsonInclude.Include.NON_NULL) 65 | @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) 66 | public static class Field { 67 | 68 | private String title; 69 | private String value; 70 | @JsonProperty("short") 71 | private boolean shortField; 72 | 73 | public static Field longField(String title, String value) { 74 | return Field.builder() 75 | .title(title) 76 | .value(value) 77 | .shortField(false) 78 | .build(); 79 | } 80 | 81 | public static Field shortField(String title, String value) { 82 | return Field.builder() 83 | .title(title) 84 | .value(value) 85 | .shortField(true) 86 | .build(); 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-firebase/src/test/java/com/hongsi/martholidayalarm/clients/firebase/message/FirebaseMessageSenderTest.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.clients.firebase.message; 2 | 3 | import com.google.firebase.messaging.ApnsConfig; 4 | import com.google.firebase.messaging.Aps; 5 | import com.google.firebase.messaging.Message; 6 | import com.google.firebase.messaging.Notification; 7 | import com.hongsi.martholidayalarm.clients.firebase.exception.PushException; 8 | import org.junit.jupiter.api.Disabled; 9 | import org.junit.jupiter.api.DisplayName; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 16 | 17 | @Disabled("local only") 18 | @SpringBootTest(properties = "spring.config.location=file:${HOME}/app/mart-holiday-alarm/conf/local-application.yml") 19 | class FirebaseMessageSenderTest { 20 | 21 | @Autowired 22 | private FirebaseMessageSender firebaseMessageSender; 23 | 24 | @Test 25 | @DisplayName("등록된 토큰으로 푸시 전송") 26 | void sendByToken() { 27 | // given 28 | String deviceToken = "doCqjq3EsWQ:APA91bGiD_YtXdDFCauOAKGzM65nHhlIubjqd9sHQer9FpD4LJLI5y2gEFsVdIe_ldq1p2NPQfpTOswAmFPkf8iVQnVzY00QdSNITkDuyENBM3GDH5GSvqGNVaBZVgexlCkv424D1TQJ"; 29 | Message message = Message.builder() 30 | .setToken(deviceToken) 31 | .setNotification(new Notification("테스트", "테스트내용")) 32 | .setApnsConfig(ApnsConfig.builder() 33 | .setAps(Aps.builder() 34 | .setSound("default") 35 | .setThreadId("martholidayapp") 36 | .build()) 37 | .build()) 38 | .build(); 39 | 40 | // when 41 | String messageId = firebaseMessageSender.send(message); 42 | 43 | // then 44 | assertThat(messageId).isNotBlank(); 45 | } 46 | 47 | @Test 48 | @DisplayName("삭제된 토큰에 푸시를 보내면 실패") 49 | void sendByDeletedToken() { 50 | // given 51 | String deviceToken = "cSE1EGF8Lxs:APA91bFoaZVweP4OKXq9S1DoZG_pTp5lM910NKbzQryMirqNEhXG8tVjZNXM8jzqw4tPW8Re20sRgUPmlAoNk_fW8xZfz-uQTPf5YigyDZiLmGhV7fag3ALvynLWDh4U4IapZaE-DBRJ"; 52 | Message message = Message.builder() 53 | .setToken(deviceToken) 54 | .setNotification(new Notification("테스트", "테스트내용")) 55 | .setApnsConfig(ApnsConfig.builder() 56 | .setAps(Aps.builder() 57 | .setSound("default") 58 | .setThreadId("martholidayapp") 59 | .build()) 60 | .build()) 61 | .build(); 62 | 63 | // expect 64 | assertThatThrownBy(() -> firebaseMessageSender.send(message)) 65 | .isInstanceOf(PushException.class) 66 | .hasMessage("Requested entity was not found.") 67 | .hasFieldOrPropertyWithValue("errorCode", PushErrorCode.DELETED_TOKEN); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /mart-holiday-alarm-api/src/main/java/com/hongsi/martholidayalarm/api/controller/MartController.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.api.controller; 2 | 3 | import com.hongsi.martholidayalarm.api.controller.converter.MartTypeParameterConverter; 4 | import com.hongsi.martholidayalarm.api.dto.ApiResponse; 5 | import com.hongsi.martholidayalarm.api.dto.mart.LocationParameter; 6 | import com.hongsi.martholidayalarm.api.dto.mart.MartOrder; 7 | import com.hongsi.martholidayalarm.api.dto.mart.MartSortParser; 8 | import com.hongsi.martholidayalarm.api.service.MartService; 9 | import com.hongsi.martholidayalarm.core.mart.MartType; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.data.domain.Sort; 12 | import org.springframework.web.bind.WebDataBinder; 13 | import org.springframework.web.bind.annotation.*; 14 | 15 | import javax.validation.Valid; 16 | import java.util.List; 17 | import java.util.Set; 18 | 19 | @RestController 20 | @RequestMapping("/api/marts") 21 | @RequiredArgsConstructor 22 | public class MartController { 23 | 24 | private final MartService martService; 25 | 26 | @GetMapping 27 | public ApiResponse getMarts(@RequestParam(name = "sort", required = false) List orders) { 28 | Sort sort = MartSortParser.parse(orders, MartOrder.martType.asc(), MartOrder.branchName.asc()); 29 | return ApiResponse.ok(martService.findAll(sort)); 30 | } 31 | 32 | @GetMapping(params = "ids") 33 | public ApiResponse getMartsByIds(@RequestParam(name = "ids") Set ids, 34 | @RequestParam(name = "sort", required = false) List orders) { 35 | Sort sort = MartSortParser.parse(orders, MartOrder.id.asc()); 36 | return ApiResponse.ok(martService.findAllById(ids, sort)); 37 | } 38 | 39 | @GetMapping(value = "/{id}") 40 | public ApiResponse getMartsById(@PathVariable Long id) { 41 | return ApiResponse.ok(martService.findById(id)); 42 | } 43 | 44 | @GetMapping(value = "/types") 45 | public ApiResponse getMartTypes() { 46 | return ApiResponse.ok(martService.findAllMartTypes()); 47 | } 48 | 49 | @GetMapping(value = "/types/{martType}") 50 | public ApiResponse getMartsByMartType(@PathVariable @Valid MartType martType, 51 | @RequestParam(name = "sort", required = false) List orders) { 52 | Sort sort = MartSortParser.parse(orders, MartOrder.branchName.asc()); 53 | return ApiResponse.ok(martService.findAllByMartType(martType, sort)); 54 | } 55 | 56 | @GetMapping(params = {"latitude", "longitude"}) 57 | public ApiResponse getMartsByLocation(@Valid LocationParameter parameter) { 58 | return ApiResponse.ok(martService.findAllByLocation(parameter)); 59 | } 60 | 61 | @GetMapping(params = "latitude") 62 | public void getMartsByLocationMissingLongitude() { 63 | throw new IllegalArgumentException("경도(longitude)가 필요합니다."); 64 | } 65 | 66 | @GetMapping(params = "longitude") 67 | public void getMartsByLocationMissingLatitude() { 68 | throw new IllegalArgumentException("위도(latitude)가 필요합니다."); 69 | } 70 | 71 | @InitBinder 72 | public void initBinder(WebDataBinder dataBinder) { 73 | dataBinder.registerCustomEditor(MartType.class, new MartTypeParameterConverter()); 74 | } 75 | } 76 | 77 | -------------------------------------------------------------------------------- /mart-holiday-alarm-clients/mart-holiday-alarm-client-location-converter/src/main/java/com/hongsi/martholidayalarm/client/location/converter/kakao/KakaoLocationConverter.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.client.location.converter.kakao; 2 | 3 | import com.hongsi.martholidayalarm.client.location.converter.LocationConversion; 4 | import com.hongsi.martholidayalarm.client.location.converter.LocationConverter; 5 | import com.hongsi.martholidayalarm.client.location.converter.LocationConverterProperties; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.boot.web.client.RestTemplateBuilder; 8 | import org.springframework.http.HttpEntity; 9 | import org.springframework.http.HttpHeaders; 10 | import org.springframework.http.HttpMethod; 11 | import org.springframework.web.client.RestTemplate; 12 | import org.springframework.web.util.UriComponentsBuilder; 13 | 14 | import java.util.Objects; 15 | import java.util.function.Supplier; 16 | 17 | @Slf4j 18 | public class KakaoLocationConverter implements LocationConverter { 19 | 20 | private final LocationConverterProperties properties; 21 | private final RestTemplate restTemplate; 22 | private final HttpEntity requestHttpEntity; 23 | 24 | public KakaoLocationConverter(RestTemplateBuilder restTemplateBuilder, 25 | LocationConverterProperties kakaoProperties) { 26 | this.properties = kakaoProperties; 27 | this.restTemplate = restTemplateBuilder.build(); 28 | this.requestHttpEntity = createHttpEntityByProperties(); 29 | } 30 | 31 | private HttpEntity createHttpEntityByProperties() { 32 | HttpHeaders httpHeaders = new HttpHeaders(); 33 | httpHeaders.set("Authorization", "KakaoAK " + properties.getClientId()); 34 | return new HttpEntity<>(httpHeaders); 35 | } 36 | 37 | @Override 38 | public LocationConversion convert(Supplier querySupplier, Supplier fallbackSupplier) { 39 | String query = querySupplier.get(); 40 | try { 41 | return requestToApi(properties.getSearchUrl(), query); 42 | } catch (Exception e) { 43 | log.warn("failed to location convert. query: {}, message: {}", query, e.getMessage()); 44 | return requestByFallback(fallbackSupplier.get()); 45 | } 46 | } 47 | 48 | private LocationConversion requestToApi(String requestUrl, String query) { 49 | KakaoLocationSearchResult kakaoLocationSearchResult = restTemplate 50 | .exchange( 51 | UriComponentsBuilder.fromHttpUrl(requestUrl) 52 | .queryParam("query", query) 53 | .queryParam("page", 1) 54 | .queryParam("size", 5) 55 | .build().toUriString(), HttpMethod.GET, requestHttpEntity, KakaoLocationSearchResult.class 56 | ) 57 | .getBody(); 58 | return Objects.requireNonNull(kakaoLocationSearchResult).getLocationConversion(); 59 | } 60 | 61 | private LocationConversion requestByFallback(String query) { 62 | try { 63 | return requestToApi(properties.getAddressUrl(), query); 64 | } catch (Exception e) { 65 | log.error("failed to location fallback. query: {}, message: {}", query, e.getMessage()); 66 | return LocationConversion.EMPTY; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /mart-holiday-alarm-push/src/main/java/com/hongsi/martholidayalarm/push/utils/StopWatch.java: -------------------------------------------------------------------------------- 1 | package com.hongsi.martholidayalarm.push.utils; 2 | 3 | import java.text.NumberFormat; 4 | import java.time.Duration; 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import java.util.Map; 8 | import java.util.concurrent.ConcurrentHashMap; 9 | import java.util.concurrent.TimeUnit; 10 | 11 | public class StopWatch { 12 | 13 | private static final TimeUnit DEFAULT_TIME_UNIT = TimeUnit.MILLISECONDS; 14 | 15 | private final String name; 16 | private final Map currentTasks = new ConcurrentHashMap<>(); 17 | private final List completedTasks = new ArrayList<>(); 18 | 19 | private long totalElapsedTime = 0L; 20 | 21 | public StopWatch() { 22 | this(""); 23 | } 24 | 25 | public StopWatch(String name) { 26 | this.name = name; 27 | } 28 | 29 | public void start(String taskName) { 30 | if (taskName == null) { 31 | throw new IllegalArgumentException("Invalid task name"); 32 | } 33 | if (currentTasks.containsKey(taskName)) { 34 | throw new IllegalArgumentException("Already working : " + taskName); 35 | } 36 | currentTasks.put(taskName, getCurrentTimeMillis()); 37 | } 38 | 39 | public void stop(String taskName) { 40 | if (!currentTasks.containsKey(taskName)) { 41 | throw new IllegalArgumentException("Not found task : " + taskName); 42 | } 43 | long elapsedTime = getCurrentTimeMillis() - currentTasks.remove(taskName); 44 | totalElapsedTime += elapsedTime; 45 | completedTasks.add(new TaskInfo(taskName, elapsedTime)); 46 | } 47 | 48 | public long getTotalElapsedTime(TimeUnit timeUnit) { 49 | return timeUnit.convert(totalElapsedTime, DEFAULT_TIME_UNIT); 50 | } 51 | 52 | /** 53 | * This code is from org.springframework.util.StopWatch 54 | */ 55 | public String prettyPrint() { 56 | StringBuilder sb = new StringBuilder(shortSummary()); 57 | sb.append("\n\n"); 58 | if (completedTasks.isEmpty()) { 59 | sb.append("No task info kept"); 60 | } else { 61 | sb.append("-----------------------------------------\n"); 62 | sb.append("ms % Task name\n"); 63 | sb.append("-----------------------------------------\n"); 64 | NumberFormat nf = NumberFormat.getNumberInstance(); 65 | nf.setMinimumIntegerDigits(5); 66 | nf.setGroupingUsed(false); 67 | NumberFormat pf = NumberFormat.getPercentInstance(); 68 | pf.setMinimumIntegerDigits(3); 69 | pf.setGroupingUsed(false); 70 | for (TaskInfo task : completedTasks) { 71 | sb.append(nf.format(task.getElapsedTime())).append(" "); 72 | sb.append(pf.format(toSeconds(task.getElapsedTime()) / toSeconds(totalElapsedTime))).append(" "); 73 | sb.append(task.getName()).append("\n"); 74 | } 75 | } 76 | return sb.toString(); 77 | } 78 | 79 | public String shortSummary() { 80 | return "StopWatch '" + name + "': total elapsed time = " + Duration.ofMillis(totalElapsedTime).toMinutes() + " minutes"; 81 | } 82 | 83 | private double toSeconds(long elapsedTime) { 84 | return elapsedTime / 1000.0; 85 | } 86 | 87 | private Long getCurrentTimeMillis() { 88 | return System.currentTimeMillis(); 89 | } 90 | } 91 | --------------------------------------------------------------------------------