diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 4c38f392..a49c3af6 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -6,7 +6,7 @@ on: branches: [ "Master", "develop", "weekly/*" ] permissions: - contents: read + contents: read jobs: # Spring Boot 애플리케이션을 빌드하여 도커허브에 푸시하는 과정 @@ -14,42 +14,42 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - # Java 17 세팅 - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - # Gradle Wrapper 파일 실행 권한 주기 - - name: Run chmod to make gradlew executable - run: chmod +x ./gradlew - - # Spring Boot 애플리케이션 빌드 - - name: Build with Gradle - uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 - with: - arguments: clean bootJar - - # 테스트 실행 -# - name: Run tests -# run: ./gradlew test - - # Docker 이미지 빌드 - - name: docker image build - run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/inplace . - - # DockerHub 로그인 - - name: docker login - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - - # Docker Hub 이미지 푸시 - - name: docker Hub push - run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/inplace + - uses: actions/checkout@v4 + # Java 17 세팅 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + # Gradle Wrapper 파일 실행 권한 주기 + - name: Run chmod to make gradlew executable + run: chmod +x ./gradlew + + # Spring Boot 애플리케이션 빌드 + - name: Build with Gradle + uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 + with: + arguments: clean bootJar + + # 테스트 실행 + # - name: Run tests + # run: ./gradlew test + + # Docker 이미지 빌드 + - name: docker image build + run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/inplace . + + # DockerHub 로그인 + - name: docker login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + # Docker Hub 이미지 푸시 + - name: docker Hub push + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/inplace # 위 과정에서 푸시한 이미지를 ec2에서 풀받아서 실행시키는 과정 @@ -73,12 +73,7 @@ jobs: # .env 파일 생성 - name: Create .env file run: | - echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env - echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" >> .env - echo "KAKAO_REDIRECT_URI=${{ secrets.KAKAO_REDIRECT_URI }}" >> .env - echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env - echo "JWT_ACCESS_TOKEN_EXPIRED_TIME=${{ secrets.JWT_ACCESS_TOKEN_EXPIRED_TIME }}" >> .env - echo "JWT_REFRESH_TOKEN_EXPIRED_TIME=${{ secrets.JWT_REFRESH_TOKEN_EXPIRED_TIME }}" >> .env + echo "${{ secrets.ENV }}" >> .env # 브랜치에 따라 다른 환경 변수를 설정하여 컨테이너 실행 - name: docker run new container @@ -88,7 +83,7 @@ jobs: elif [ "${{ github.ref }}" == "refs/heads/develop" ]; then sudo docker run --name inplace-dev --rm -d -p 8081:8080 --env-file .env ${{ secrets.DOCKERHUB_USERNAME }}/inplace fi - + # 미사용 이미지를 정리 - name: delete old docker image run: sudo docker system prune -f diff --git a/.gitignore b/.gitignore index e536363b..fa243ae5 100644 --- a/.gitignore +++ b/.gitignore @@ -205,7 +205,9 @@ gradle-app.setting .env /src/test/resources/application-test.yaml -# CommandLineRunner for loading dummy data -src/main/java/team7/inplace/place/config/DataLoader.java +# Ignore specific application config files +application.yaml +application-security.yaml +application-youtube.yaml # End of https://www.toptal.com/developers/gitignore/api/macos,java,intellij,gradle diff --git a/build.gradle b/build.gradle index 19f40b08..507c14a6 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.3' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/team7/inplace/crawling/application/AddressUtil.java b/src/main/java/team7/inplace/crawling/application/AddressUtil.java new file mode 100644 index 00000000..5ebd55cb --- /dev/null +++ b/src/main/java/team7/inplace/crawling/application/AddressUtil.java @@ -0,0 +1,25 @@ +package team7.inplace.crawling.application; + +import static lombok.AccessLevel.PRIVATE; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = PRIVATE) +public class AddressUtil { + private static final String ADDRESS_REGEX = "[가-힣0-9]+(?:도|시|구|군|읍|면|동|리|로|길)[^#,\\n()]+(?:동|읍|면|리|로|길|호|층|번지)[^#,\\n()]+"; + + public static String extractAddress(JsonNode snippet) { + + String videoDescription = snippet.path("description").asText(); + + Pattern pattern = Pattern.compile(ADDRESS_REGEX); + Matcher matcher = pattern.matcher(videoDescription); + if (matcher.find()) { + return matcher.group(); + } + return null; + } +} diff --git a/src/main/java/team7/inplace/crawling/application/CrawlingFacade.java b/src/main/java/team7/inplace/crawling/application/CrawlingFacade.java new file mode 100644 index 00000000..cf92a42d --- /dev/null +++ b/src/main/java/team7/inplace/crawling/application/CrawlingFacade.java @@ -0,0 +1,22 @@ +package team7.inplace.crawling.application; + +import lombok.RequiredArgsConstructor; +import team7.inplace.global.annotation.Facade; +import team7.inplace.video.application.VideoFacade; + +@Facade +@RequiredArgsConstructor +public class CrawlingFacade { + private final YoutubeCrawlingService youtubeCrawlingService; + private final VideoFacade videoFacade; + + public void updateVideos() { + var crawlingInfos = youtubeCrawlingService.crawlAllVideos(); + for (var crawlingInfo : crawlingInfos) { + var videoCommands = crawlingInfo.toVideoCommands(); + var placesCommands = crawlingInfo.toPlacesCommands(); + + videoFacade.createVideos(videoCommands, placesCommands); + } + } +} diff --git a/src/main/java/team7/inplace/crawling/application/YoutubeCrawlingService.java b/src/main/java/team7/inplace/crawling/application/YoutubeCrawlingService.java index 375866bd..9a3020d1 100644 --- a/src/main/java/team7/inplace/crawling/application/YoutubeCrawlingService.java +++ b/src/main/java/team7/inplace/crawling/application/YoutubeCrawlingService.java @@ -1,9 +1,11 @@ package team7.inplace.crawling.application; +import java.util.List; import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import team7.inplace.crawling.application.dto.CrawlingInfo; import team7.inplace.crawling.client.KakaoMapClient; import team7.inplace.crawling.client.YoutubeClient; import team7.inplace.crawling.persistence.YoutubeChannelRepository; @@ -22,17 +24,30 @@ public class YoutubeCrawlingService { 3. 마지막 비디오 UUID를 업데이트 한다. 4. 카카오 API를 호출해 장소 정보를 가져온다 */ - public void crawlAllVideos() { + public List crawlAllVideos() { var youtubeChannels = youtubeChannelRepository.findAll(); - for (var channel : youtubeChannels) { - var rawVideoInfos = youtubeClient.getVideos(channel.getPlayListUUID(), channel.getLastVideoUUID()); - channel.updateLastVideoUUID(rawVideoInfos.get(0).videoId()); - - var videos = rawVideoInfos.stream() - .map(rawVideoInfo -> kakaoMapClient.search(rawVideoInfo, channel.getChannelType().getCode())) - .filter(Objects::nonNull) - .toList(); - } + + var crawlInfos = youtubeChannels.stream() + .map(channel -> { + var videoSnippets = youtubeClient.getVideos(channel.getPlayListUUID(), channel.getLastVideoUUID()); + + var videoAddresses = videoSnippets.stream() + .map(AddressUtil::extractAddress) + .toList(); + + var placeNodes = videoAddresses.stream() + .map(address -> { + if (Objects.isNull(address)) { + return null; + } + return kakaoMapClient.search(address, channel.getChannelType().getCode()); + }) + .toList(); + + return new CrawlingInfo(channel.getInfluencerId(), videoSnippets, placeNodes); + }).toList(); + + return crawlInfos; } } diff --git a/src/main/java/team7/inplace/crawling/application/dto/CrawlingInfo.java b/src/main/java/team7/inplace/crawling/application/dto/CrawlingInfo.java new file mode 100644 index 00000000..7ed84962 --- /dev/null +++ b/src/main/java/team7/inplace/crawling/application/dto/CrawlingInfo.java @@ -0,0 +1,25 @@ +package team7.inplace.crawling.application.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import team7.inplace.crawling.client.dto.PlaceNode; +import team7.inplace.place.application.command.PlacesCommand; +import team7.inplace.video.application.command.VideoCommand; + +public record CrawlingInfo( + Long influencerId, + List videoSnippets, + List placeNodes +) { + public List toVideoCommands() { + return videoSnippets.stream() + .map(snippet -> VideoCommand.Create.from(snippet, influencerId)) + .toList(); + } + + public List toPlacesCommands() { + return placeNodes.stream() + .map(placeNode -> PlacesCommand.Create.from(placeNode.locationNode(), placeNode.placeNode())) + .toList(); + } +} diff --git a/src/main/java/team7/inplace/crawling/client/KakaoMapClient.java b/src/main/java/team7/inplace/crawling/client/KakaoMapClient.java index 992f5cdb..b56fc71f 100644 --- a/src/main/java/team7/inplace/crawling/client/KakaoMapClient.java +++ b/src/main/java/team7/inplace/crawling/client/KakaoMapClient.java @@ -10,8 +10,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import team7.inplace.crawling.client.dto.RawPlace; -import team7.inplace.crawling.client.dto.RawVideoInfo; +import team7.inplace.crawling.client.dto.PlaceNode; import team7.inplace.global.kakao.config.KakaoApiProperties; @Slf4j @@ -24,8 +23,7 @@ public class KakaoMapClient { private final KakaoApiProperties kakaoApiProperties; private final RestTemplate restTemplate; - public RawPlace.Info search(RawVideoInfo videoInfo, String category) { - var address = videoInfo.address(); + public PlaceNode search(String address, String category) { var locationInfo = getLocateInfo(address, category); var placeId = locationInfo.has("documents") ? locationInfo.get("documents").get(0).get("id").asText() : null; @@ -34,7 +32,7 @@ public RawPlace.Info search(RawVideoInfo videoInfo, String category) { } var placeInfo = getPlaceInfo(placeId); - return RawPlace.Info.from(locationInfo, placeInfo); + return PlaceNode.of(locationInfo, placeInfo); } private JsonNode getLocateInfo(String address, String category) { diff --git a/src/main/java/team7/inplace/crawling/client/YoutubeClient.java b/src/main/java/team7/inplace/crawling/client/YoutubeClient.java index 94e86359..b9187104 100644 --- a/src/main/java/team7/inplace/crawling/client/YoutubeClient.java +++ b/src/main/java/team7/inplace/crawling/client/YoutubeClient.java @@ -4,31 +4,26 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import team7.inplace.crawling.client.dto.RawVideoInfo; @Slf4j @Component public class YoutubeClient { private static final String PLAY_LIST_ITEMS_BASE_URL = "https://www.googleapis.com/youtube/v3/playlistItems"; private static final String PLAY_LIST_PARAMS = "?part=snippet&playlistId=%s&key=%s&maxResults=50"; - private static final String ADDRESS_REGEX = "[가-힣0-9]+(?:도|시|구|군|읍|면|동|리|로|길)[^#,\\n()]+(?:동|읍|면|리|로|길|호|층|번지)[^#,\\n()]+"; private final RestTemplate restTemplate; private final String apiKey; public YoutubeClient(@Value("${youtube.api.key}") String apiKey, RestTemplate restTemplate) { - log.info("Youtube API Key: {}", apiKey); this.restTemplate = restTemplate; this.apiKey = apiKey; } - public List getVideos(String playListId, String finalVideoUUID) { - List videoInfos = new ArrayList<>(); + public List getVideos(String playListId, String finalVideoUUID) { + List snippets = new ArrayList<>(); String nextPageToken = null; while (true) { String url = PLAY_LIST_ITEMS_BASE_URL + String.format(PLAY_LIST_PARAMS, playListId, apiKey); @@ -41,7 +36,6 @@ public List getVideos(String playListId, String finalVideoUUID) { response = restTemplate.getForObject(url, JsonNode.class); } catch (Exception e) { log.error("Youtube API 호출이 실패했습니다. Youtuber Id {}", playListId); - log.info(e.getMessage()); break; } if (Objects.isNull(response)) { @@ -49,7 +43,7 @@ public List getVideos(String playListId, String finalVideoUUID) { break; } - var containsLastVideo = extractRawVideoInfo(videoInfos, response.path("items"), finalVideoUUID); + var containsLastVideo = extractSnippets(snippets, response.path("items"), finalVideoUUID); if (containsLastVideo) { break; } @@ -58,39 +52,22 @@ public List getVideos(String playListId, String finalVideoUUID) { break; } } - return videoInfos; + return snippets; } private boolean isLastPage(String nextPageToken) { return Objects.isNull(nextPageToken) || nextPageToken.isEmpty(); } - private boolean extractRawVideoInfo(List videoInfos, JsonNode items, String finalVideoUUID) { + private boolean extractSnippets(List snippets, JsonNode items, String finalVideoUUID) { for (JsonNode item : items) { var snippet = item.path("snippet"); var videoId = snippet.path("resourceId").path("videoId").asText(); - var videoTitle = snippet.path("title").asText(); - var videoDescription = snippet.path("description").asText(); if (videoId.equals(finalVideoUUID)) { return true; } - - var address = extractAddress(videoDescription); - if (Objects.nonNull(address)) { - videoInfos.add(new RawVideoInfo(videoId, videoTitle, address)); - continue; - } - log.info("주소를 찾을 수 없습니다. {}", videoDescription); + snippets.add(snippet); } return false; } - - private String extractAddress(String description) { - Pattern pattern = Pattern.compile(ADDRESS_REGEX); - Matcher matcher = pattern.matcher(description); - if (matcher.find()) { - return matcher.group(); - } - return null; - } } diff --git a/src/main/java/team7/inplace/crawling/client/dto/PlaceNode.java b/src/main/java/team7/inplace/crawling/client/dto/PlaceNode.java new file mode 100644 index 00000000..4f919cc3 --- /dev/null +++ b/src/main/java/team7/inplace/crawling/client/dto/PlaceNode.java @@ -0,0 +1,12 @@ +package team7.inplace.crawling.client.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +public record PlaceNode( + JsonNode locationNode, + JsonNode placeNode +) { + public static PlaceNode of(JsonNode locationNode, JsonNode placeNode) { + return new PlaceNode(locationNode, placeNode); + } +} diff --git a/src/main/java/team7/inplace/crawling/client/dto/RawPlace.java b/src/main/java/team7/inplace/crawling/client/dto/RawPlace.java deleted file mode 100644 index feb9f7ad..00000000 --- a/src/main/java/team7/inplace/crawling/client/dto/RawPlace.java +++ /dev/null @@ -1,138 +0,0 @@ -package team7.inplace.crawling.client.dto; - -import com.fasterxml.jackson.databind.JsonNode; -import java.util.ArrayList; -import java.util.List; - -public class RawPlace { - public record Info( - String placeName, - String facility, - String menuImgUrl, - String category, - String address, - String x, - String y, - List offDays, - List openPeriods, - List menus - ) { - public static Info from(JsonNode locationNode, JsonNode placeNode) { - var basicInfo = placeNode.get("basicInfo"); - - String placeName = - basicInfo.has("placenamefull") ? basicInfo.get("placenamefull").asText() : "Unknown Place"; - String facility = basicInfo.has("facilityInfo") - ? basicInfo.get("facilityInfo").toString() : "N/A"; - - String menuImgUrl = basicInfo.has("mainphotourl") ? basicInfo.get("mainphotourl").asText() : ""; - String category = basicInfo.has("category") && basicInfo.get("category").has("catename") - ? basicInfo.get("category").get("catename").asText() : "Unknown Category"; - String address = - basicInfo.has("address") && basicInfo.get("address").has("region") && basicInfo.get("address") - .get("region").has("newaddrfullname") - ? basicInfo.get("address").get("region").get("newaddrfullname").asText() - : "Unknown Address"; - String addressDetail = - basicInfo.has("address") && basicInfo.get("address").has("newaddr") && basicInfo.get("address") - .get("newaddr").has("newaddrfull") - ? basicInfo.get("address").get("newaddr").get("newaddrfull").asText() : ""; - - String x = locationNode.has("documents") && locationNode.get("documents").get(0).has("x") - ? locationNode.get("documents").get(0).get("x").asText() : "0.0"; - String y = locationNode.has("documents") && locationNode.get("documents").get(0).has("y") - ? locationNode.get("documents").get(0).get("y").asText() : "0.0"; - - var timeList = basicInfo.has("openHour") ? basicInfo.get("openHour") : null; - var openPeriods = extractOpenPeriods(timeList.has("periodList") ? timeList.get("periodList") : null); - var offDays = extractOffDays(timeList.has("offdayList") ? timeList.get("offdayList") : null); - var menus = extractMenus(placeNode.has("menuInfo") ? placeNode.get("menuInfo") : null); - - return new Info(placeName, facility, menuImgUrl, category, address + " " + addressDetail, x, y, offDays, - openPeriods, menus); - } - - private static List extractOpenPeriods(JsonNode openTimeList) { - if (openTimeList == null) { - return new ArrayList<>(); - } - List openTimes = new ArrayList<>(); - for (JsonNode openTimeNode : openTimeList) { - for (JsonNode timeNode : openTimeNode.get("timeList")) { - openTimes.add(OpenTime.from(timeNode)); - } - } - return openTimes; - } - - private static List extractOffDays(JsonNode offdayList) { - if (offdayList == null) { - return new ArrayList<>(); // 빈 리스트를 반환하여 null 회피 - } - List offDays = new ArrayList<>(); - for (JsonNode offDayNode : offdayList) { - offDays.add(OffDay.from(offDayNode)); - } - return offDays; - } - - private static List extractMenus(JsonNode menuList) { - if (menuList == null) { - return new ArrayList<>(); - } - List menus = new ArrayList<>(); - for (JsonNode menuNode : menuList.get("menuList")) { - menus.add(Menu.from(menuNode)); - } - return menus; - } - } - - public record OffDay( - String holidayName, - String weekAndDay, - String temporaryHolidays - ) { - public static OffDay from(JsonNode offDayNode) { - String holidayName = offDayNode != null && offDayNode.has("holidayName") - ? offDayNode.get("holidayName").asText() : "Unknown Holiday"; - String weekAndDay = offDayNode != null && offDayNode.has("weekAndDay") - ? offDayNode.get("weekAndDay").asText() : "Unknown Week And Day"; - String temporaryHolidays = offDayNode != null && offDayNode.has("temporaryHolidays") - ? offDayNode.get("temporaryHolidays").asText() : "No Temporary Holidays"; - return new OffDay(holidayName, weekAndDay, temporaryHolidays); - } - } - - public record OpenTime( - String timeName, - String timeSE, - String dayOfWeek - ) { - public static OpenTime from(JsonNode openTimeNode) { - String timeName = openTimeNode != null && openTimeNode.has("timeName") - ? openTimeNode.get("timeName").asText() : "Unknown Time Name"; - String timeSE = openTimeNode != null && openTimeNode.has("timeSE") - ? openTimeNode.get("timeSE").asText() : "Unknown Time Range"; - String dayOfWeek = openTimeNode != null && openTimeNode.has("dayOfWeek") - ? openTimeNode.get("dayOfWeek").asText() : "Unknown Day Of Week"; - return new OpenTime(timeName, timeSE, dayOfWeek); - } - } - - public record Menu( - String menuName, - String menuPrice, - boolean recommend - ) { - public static Menu from(JsonNode menuNode) { - String menuName = menuNode != null && menuNode.has("menu") - ? menuNode.get("menu").asText() : "Unknown Menu"; - String menuPrice = menuNode != null && menuNode.has("price") - ? menuNode.get("price").asText() : "0"; - boolean recommend = menuNode != null && menuNode.has("recommend") - && menuNode.get("recommend").asBoolean(); - return new Menu(menuName, menuPrice, recommend); - } - } -} \ No newline at end of file diff --git a/src/main/java/team7/inplace/crawling/client/dto/RawVideoInfo.java b/src/main/java/team7/inplace/crawling/client/dto/RawVideoInfo.java deleted file mode 100644 index 0faedd74..00000000 --- a/src/main/java/team7/inplace/crawling/client/dto/RawVideoInfo.java +++ /dev/null @@ -1,8 +0,0 @@ -package team7.inplace.crawling.client.dto; - -public record RawVideoInfo( - String videoId, - String videoTitle, - String address -) { -} diff --git a/src/main/java/team7/inplace/global/annotation/Facade.java b/src/main/java/team7/inplace/global/annotation/Facade.java new file mode 100644 index 00000000..1b0f2071 --- /dev/null +++ b/src/main/java/team7/inplace/global/annotation/Facade.java @@ -0,0 +1,20 @@ +package team7.inplace.global.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface Facade { + @AliasFor( + annotation = Component.class + ) + String value() default ""; +} diff --git a/src/main/java/team7/inplace/global/exception/code/VideoErrorCode.java b/src/main/java/team7/inplace/global/exception/code/VideoErrorCode.java new file mode 100644 index 00000000..483b97d0 --- /dev/null +++ b/src/main/java/team7/inplace/global/exception/code/VideoErrorCode.java @@ -0,0 +1,30 @@ +package team7.inplace.global.exception.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum VideoErrorCode implements ErrorCode{ + NO_SUCH_VIDEO(HttpStatus.NOT_FOUND, "V001", "Can't find such video info"); + + private final HttpStatus httpStatus; + private final String errorCode; + private final String message; + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String code() { + return errorCode; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/team7/inplace/influencer/application/InfluencerService.java b/src/main/java/team7/inplace/influencer/application/InfluencerService.java index ca1c0c0b..250d62d5 100644 --- a/src/main/java/team7/inplace/influencer/application/InfluencerService.java +++ b/src/main/java/team7/inplace/influencer/application/InfluencerService.java @@ -4,14 +4,27 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import team7.inplace.global.exception.InplaceException; +import team7.inplace.global.exception.code.AuthorizationErrorCode; +import team7.inplace.influencer.application.dto.InfluencerCommand; import team7.inplace.influencer.application.dto.InfluencerInfo; +import team7.inplace.influencer.domain.Influencer; import team7.inplace.influencer.persistence.InfluencerRepository; +import team7.inplace.influencer.presentation.dto.InfluencerRequestParam; +import team7.inplace.security.util.AuthorizationUtil; +import team7.inplace.user.domain.User; +import team7.inplace.user.persistence.UserRepository; +import team7.inplace.userFavoriteInfluencer.domain.UserFavoriteInfluencer; +import team7.inplace.userFavoriteInfluencer.persistent.UserFavoriteInfluencerRepository; @RequiredArgsConstructor @Service public class InfluencerService { private final InfluencerRepository influencerRepository; + private final UserFavoriteInfluencerRepository favoriteRepository; + private final UserRepository userRepository; @Transactional(readOnly = true) public List getAllInfluencers() { @@ -19,4 +32,40 @@ public List getAllInfluencers() { .map(InfluencerInfo::from) .toList(); } + + @Transactional + public Long createInfluencer(InfluencerCommand command) { + Influencer influencer = InfluencerCommand.to(command); + return influencerRepository.save(influencer).getId(); + } + + @Transactional + public Long updateInfluencer(Long id, InfluencerCommand command) { + Influencer influencer = influencerRepository.findById(id).orElseThrow(); + influencer.update(command.influencerName(), command.influencerImgUrl(), + command.influencerJob()); + + return influencer.getId(); + } + + @Transactional + public void deleteInfluencer(Long id) { + Influencer influencer = influencerRepository.findById(id).orElseThrow(); + + influencerRepository.delete(influencer); + } + + public void likeToInfluencer(InfluencerRequestParam param) { + String username = AuthorizationUtil.getUsername(); + if (StringUtils.hasText(username)) { + throw InplaceException.of(AuthorizationErrorCode.TOKEN_IS_EMPTY); + } + + User user = userRepository.findByUsername(username).orElseThrow(); + Influencer influencer = influencerRepository.findById(param.influencerId()).orElseThrow(); + + UserFavoriteInfluencer favorite = new UserFavoriteInfluencer(user, influencer); + favorite.check(param.likes()); + favoriteRepository.save(favorite); + } } diff --git a/src/main/java/team7/inplace/influencer/application/dto/InfluencerCommand.java b/src/main/java/team7/inplace/influencer/application/dto/InfluencerCommand.java new file mode 100644 index 00000000..c0ac6e03 --- /dev/null +++ b/src/main/java/team7/inplace/influencer/application/dto/InfluencerCommand.java @@ -0,0 +1,18 @@ +package team7.inplace.influencer.application.dto; + +import team7.inplace.influencer.domain.Influencer; + +public record InfluencerCommand( + String influencerName, + String influencerImgUrl, + String influencerJob +) { + + public static Influencer to(InfluencerCommand influencerCommand) { + return new Influencer( + influencerCommand.influencerName, + influencerCommand.influencerImgUrl, + influencerCommand.influencerJob + ); + } +} diff --git a/src/main/java/team7/inplace/influencer/config/InfluencerDataLoader.java b/src/main/java/team7/inplace/influencer/config/InfluencerDataLoader.java index 0337d27c..dc63cab4 100644 --- a/src/main/java/team7/inplace/influencer/config/InfluencerDataLoader.java +++ b/src/main/java/team7/inplace/influencer/config/InfluencerDataLoader.java @@ -15,9 +15,9 @@ public class InfluencerDataLoader implements ApplicationRunner { @Override public void run(ApplicationArguments args) throws Exception { - Influencer influencer1 = new Influencer("Influencer 1", "job1", "imgUrl1"); - Influencer influencer2 = new Influencer("Influencer 2", "job2", "imgUrl2"); - Influencer influencer3 = new Influencer("Influencer 3", "job3", "imgUrl3"); + Influencer influencer1 = new Influencer("Influencer 1", "imgUrl1", "job1"); + Influencer influencer2 = new Influencer("Influencer 2", "imgUrl2", "job2"); + Influencer influencer3 = new Influencer("Influencer 3", "imgUrl3", "job3"); influencerRepository.save(influencer1); influencerRepository.save(influencer2); diff --git a/src/main/java/team7/inplace/influencer/domain/Influencer.java b/src/main/java/team7/inplace/influencer/domain/Influencer.java index ed87c896..e35d1899 100644 --- a/src/main/java/team7/inplace/influencer/domain/Influencer.java +++ b/src/main/java/team7/inplace/influencer/domain/Influencer.java @@ -7,12 +7,13 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import lombok.Builder; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor @Entity public class Influencer { @@ -29,10 +30,15 @@ public class Influencer { @Column(nullable = false, columnDefinition = "TEXT") private String imgUrl; - @Builder - public Influencer(String name, String job, String imgUrl) { + public Influencer(String name, String imgUrl, String job) { this.name = name; + this.imgUrl = imgUrl; this.job = job; + } + + public void update(String name, String imgUrl, String job) { + this.name = name; this.imgUrl = imgUrl; + this.job = job; } } diff --git a/src/main/java/team7/inplace/influencer/presentation/InfluencerController.java b/src/main/java/team7/inplace/influencer/presentation/InfluencerController.java index dbe86ed4..2fe8ef00 100644 --- a/src/main/java/team7/inplace/influencer/presentation/InfluencerController.java +++ b/src/main/java/team7/inplace/influencer/presentation/InfluencerController.java @@ -4,20 +4,29 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import team7.inplace.influencer.application.InfluencerService; +import team7.inplace.influencer.application.dto.InfluencerCommand; import team7.inplace.influencer.application.dto.InfluencerInfo; import team7.inplace.influencer.presentation.dto.InfluencerListResponse; +import team7.inplace.influencer.presentation.dto.InfluencerRequest; +import team7.inplace.influencer.presentation.dto.InfluencerRequestParam; import team7.inplace.influencer.presentation.dto.InfluencerResponse; @RequiredArgsConstructor @RestController -public class InfluencerController { +public class InfluencerController implements InfluencerControllerApiSpec { private final InfluencerService influencerService; - @GetMapping("/influencers") + @GetMapping() public ResponseEntity getAllInfluencers() { List influencersDtoList = influencerService.getAllInfluencers(); List influencers = influencersDtoList.stream() @@ -28,4 +37,41 @@ public ResponseEntity getAllInfluencers() { return new ResponseEntity<>(response, HttpStatus.OK); } + @PostMapping("/influencers") + public ResponseEntity createInfluencer(@RequestBody InfluencerRequest request) { + InfluencerCommand influencerCommand = new InfluencerCommand( + request.influencerName(), + request.influencerImgUrl(), + request.influencerJob() + ); + Long savedId = influencerService.createInfluencer(influencerCommand); + + return new ResponseEntity<>(savedId, HttpStatus.OK); + } + + @PutMapping("/influencers/{id}") + public ResponseEntity updateInfluencer(@PathVariable Long id, + @RequestBody InfluencerRequest request) { + InfluencerCommand influencerCommand = new InfluencerCommand( + request.influencerName(), + request.influencerImgUrl(), + request.influencerJob() + ); + Long updatedId = influencerService.updateInfluencer(id, influencerCommand); + + return new ResponseEntity<>(updatedId, HttpStatus.OK); + } + + @DeleteMapping("/influencers/{id}") + public ResponseEntity deleteInfluencer(@PathVariable Long id) { + influencerService.deleteInfluencer(id); + + return new ResponseEntity<>(id, HttpStatus.OK); + } + + @PostMapping("/likes") + public ResponseEntity likeToInfluencer(@ModelAttribute InfluencerRequestParam param) { + influencerService.likeToInfluencer(param); + return new ResponseEntity<>(HttpStatus.OK); + } } diff --git a/src/main/java/team7/inplace/influencer/presentation/InfluencerControllerApiSpec.java b/src/main/java/team7/inplace/influencer/presentation/InfluencerControllerApiSpec.java new file mode 100644 index 00000000..71d8ccab --- /dev/null +++ b/src/main/java/team7/inplace/influencer/presentation/InfluencerControllerApiSpec.java @@ -0,0 +1,24 @@ +package team7.inplace.influencer.presentation; + +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import team7.inplace.influencer.presentation.dto.InfluencerListResponse; +import team7.inplace.influencer.presentation.dto.InfluencerRequest; + +public interface InfluencerControllerApiSpec { + + @Operation(summary = "인플루언서들 리스트 반환", description = "토큰 유무에 따라 좋아요한 인플루언서 반영 여부가 다릅니다.") + ResponseEntity getAllInfluencers(); + + @Operation(summary = "인플루언서 등록", description = "새 인플루언서를 등록합니다.") + ResponseEntity createInfluencer(@RequestBody InfluencerRequest request); + + @Operation(summary = "인플루언서 수정", description = "인플루언서를 수정합니다.") + ResponseEntity updateInfluencer(@PathVariable Long id, + @RequestBody InfluencerRequest request); + + @Operation(summary = "인플루언서 삭제", description = "인플루언서를 삭제합니다.") + ResponseEntity deleteInfluencer(@PathVariable Long id); +} diff --git a/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerRequest.java b/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerRequest.java new file mode 100644 index 00000000..347dad96 --- /dev/null +++ b/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerRequest.java @@ -0,0 +1,9 @@ +package team7.inplace.influencer.presentation.dto; + +public record InfluencerRequest( + String influencerName, + String influencerImgUrl, + String influencerJob +) { + +} diff --git a/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerRequestParam.java b/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerRequestParam.java new file mode 100644 index 00000000..ba3544f6 --- /dev/null +++ b/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerRequestParam.java @@ -0,0 +1,7 @@ +package team7.inplace.influencer.presentation.dto; + +public record InfluencerRequestParam( + Long influencerId, + Boolean likes +) { +} diff --git a/src/main/java/team7/inplace/place/application/PlaceService.java b/src/main/java/team7/inplace/place/application/PlaceService.java index 1fc5f560..24e354f6 100644 --- a/src/main/java/team7/inplace/place/application/PlaceService.java +++ b/src/main/java/team7/inplace/place/application/PlaceService.java @@ -3,13 +3,16 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.stereotype.Service; +import team7.inplace.place.application.command.PlacesCommand.Create; import team7.inplace.place.application.command.PlacesCommand.PlacesCoordinateCommand; import team7.inplace.place.application.command.PlacesCommand.PlacesFilterParamsCommand; +import team7.inplace.place.application.dto.PlaceDetailInfo; import team7.inplace.place.application.dto.PlaceInfo; import team7.inplace.place.domain.Place; import team7.inplace.place.persistence.PlaceRepository; @@ -25,8 +28,8 @@ public class PlaceService { private final VideoRepository videoRepository; public Page getPlacesWithinRadius( - PlacesCoordinateCommand placesCoordinateCommand, - PlacesFilterParamsCommand placesFilterParamsCommand) { + PlacesCoordinateCommand placesCoordinateCommand, + PlacesFilterParamsCommand placesFilterParamsCommand) { // categories와 influencers 필터 처리 List categoryFilters = null; @@ -35,55 +38,80 @@ public Page getPlacesWithinRadius( // 필터 값이 있을 경우에만 split 처리 if (placesFilterParamsCommand.isCategoryFilterExists()) { categoryFilters = Arrays.stream(placesFilterParamsCommand.categories().split(",")) - .toList(); + .toList(); } if (placesFilterParamsCommand.isInfluencerFilterExists()) { influencerFilters = Arrays.stream(placesFilterParamsCommand.influencers().split(",")) - .toList(); + .toList(); } // 주어진 좌표로 장소를 찾고, 해당 페이지의 결과를 가져옵니다. Page placesPage = getPlacesByDistance(placesCoordinateCommand, categoryFilters, - influencerFilters); + influencerFilters); // Place ID 목록 추출 List placeIds = placesPage.getContent().stream() - .map(Place::getId) - .toList(); + .map(Place::getId) + .toList(); // influencer 조회와 PlaceInfo 변환 List