diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 13d21f76..4c38f392 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -1,15 +1,13 @@ name: Java CI with Gradle -# Master, develop 브랜치에 push 혹은 pull request가 발생할 경우 동작한다. +# Master, develop, weekly/* 브랜치에 push가 발생할 경우 동작한다. on: push: - branches: [ "Master", "develop" ] - pull_request: - branches: [ "Master", "develop" ] + branches: [ "Master", "develop", "weekly/*" ] permissions: contents: read - + jobs: # Spring Boot 애플리케이션을 빌드하여 도커허브에 푸시하는 과정 build-docker-image: @@ -17,34 +15,39 @@ jobs: steps: - uses: actions/checkout@v4 - # 1. Java 17 세팅 + # 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 - # 2. Spring Boot 애플리케이션 빌드 + # Spring Boot 애플리케이션 빌드 - name: Build with Gradle uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 with: arguments: clean bootJar - # 3. Docker 이미지 빌드 + # 테스트 실행 +# - name: Run tests +# run: ./gradlew test + + # Docker 이미지 빌드 - name: docker image build run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/inplace . - # 4. DockerHub 로그인 + # DockerHub 로그인 - name: docker login uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - # 5. Docker Hub 이미지 푸시 + # Docker Hub 이미지 푸시 - name: docker Hub push run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/inplace @@ -55,23 +58,37 @@ jobs: needs: build-docker-image runs-on: self-hosted + # weekly/* 브랜치에서는 실행되지 않도록 조건 추가 + if: startsWith(github.ref, 'refs/heads/Master') || startsWith(github.ref, 'refs/heads/develop') + steps: - # 1. 최신 이미지를 pull + # 최신 이미지를 pull - name: docker pull run: sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/inplace - # 2. 기존의 컨테이너를 중지 + # 기존의 컨테이너를 중지 - name: docker stop container run: sudo docker stop $(sudo docker ps -q) 2>/dev/null || true - # 3. 브랜치에 따라 다른 환경 변수를 설정하여 컨테이너 실행 + # .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 + + # 브랜치에 따라 다른 환경 변수를 설정하여 컨테이너 실행 - name: docker run new container run: | - if [ "${{ github.ref }}" == "refs/heads/master" ]; then - sudo docker run --name inplace --rm -d -p 8080:8080 ${{ secrets.DOCKERHUB_USERNAME }}/inplace + if [ "${{ github.ref }}" == "refs/heads/Master" ]; then + sudo docker run --name inplace --rm -d -p 8080:8080 --env-file .env ${{ secrets.DOCKERHUB_USERNAME }}/inplace elif [ "${{ github.ref }}" == "refs/heads/develop" ]; then - sudo docker run --name inplace-dev --rm -d -p 8081:8080 ${{ secrets.DOCKERHUB_USERNAME }}/inplace - - # 4. 미사용 이미지를 정리 + 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 277899bb..e536363b 100644 --- a/.gitignore +++ b/.gitignore @@ -201,6 +201,11 @@ gradle-app.setting ### Gradle Patch ### # Java heap dump *.hprof -*.yaml -# End of https://www.toptal.com/developers/gitignore/api/macos,java,intellij,gradle \ No newline at end of file +.env +/src/test/resources/application-test.yaml + +# CommandLineRunner for loading dummy data +src/main/java/team7/inplace/place/config/DataLoader.java + +# End of https://www.toptal.com/developers/gitignore/api/macos,java,intellij,gradle diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..0ee5ec68 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/main/resources/InplaceSecurity"] + path = src/main/resources/InplaceSecurity + url = https://github.com/suhyeon7497/InplaceSecurity.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f3f3a7dc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Dockerfile + +# jdk17 Image Start +#FROM --platform=linux/amd64 ubuntu:latest +FROM openjdk:17 + +# 인자 설정 - Jar_File +ARG JAR_FILE=build/libs/*.jar + +# jar 파일 복제 +COPY ${JAR_FILE} app.jar + +# 인자 설정 부분과 jar 파일 복제 부분 합쳐서 진행해도 무방 +#COPY build/libs/*.jar app.jar + +# 실행 명령어 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md index 12ab0554..b52c56a7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,6 @@ # Team7_BE ## Project Version -- Spring Boots 3.3.3 -- Java 17 LTS ---- -## 리뷰 요청 -### PlaceControllerTest -- Mock을 사용하여 테스트를 진행했는데, 적절히 사용했는지 잘 모르겠습니다. - - PlaceService를 mock, PlaceController를 InjectedMocks로 지정했는데 블로그 찾아보니 사람마다 조금씩 달라 적절한지 궁금합니다. +- Spring Boots 3.3.3 +- Java 17 LTS \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7ee55b06..19f40b08 100644 --- a/build.gradle +++ b/build.gradle @@ -1,41 +1,46 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.3.3' - id 'io.spring.dependency-management' version '1.1.6' + id 'java' + id 'org.springframework.boot' version '3.3.3' + id 'io.spring.dependency-management' version '1.1.6' } group = 'inplace' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - - - testImplementation 'org.springframework.boot:spring-boot-starter-test' - runtimeOnly 'com.mysql:mysql-connector-j' - runtimeOnly 'com.h2database:h2' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.3' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.h2database:h2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/src/main/java/team7/inplace/InplaceApplication.java b/src/main/java/team7/inplace/InplaceApplication.java index 0f739c6a..587c3c33 100644 --- a/src/main/java/team7/inplace/InplaceApplication.java +++ b/src/main/java/team7/inplace/InplaceApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class InplaceApplication { - public static void main(String[] args) { - SpringApplication.run(InplaceApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(InplaceApplication.class, args); + } } diff --git a/src/main/java/team7/inplace/cicd/TestController.java b/src/main/java/team7/inplace/cicd/TestController.java new file mode 100644 index 00000000..34bcc01a --- /dev/null +++ b/src/main/java/team7/inplace/cicd/TestController.java @@ -0,0 +1,13 @@ +package team7.inplace.cicd; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TestController { + @GetMapping("/cicd") + public ResponseEntity test() { + return ResponseEntity.ok("ci/cd 테스트2"); + } +} diff --git a/src/main/java/team7/inplace/crawling/application/YoutubeCrawlingService.java b/src/main/java/team7/inplace/crawling/application/YoutubeCrawlingService.java new file mode 100644 index 00000000..375866bd --- /dev/null +++ b/src/main/java/team7/inplace/crawling/application/YoutubeCrawlingService.java @@ -0,0 +1,38 @@ +package team7.inplace.crawling.application; + +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import team7.inplace.crawling.client.KakaoMapClient; +import team7.inplace.crawling.client.YoutubeClient; +import team7.inplace.crawling.persistence.YoutubeChannelRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class YoutubeCrawlingService { + private final YoutubeChannelRepository youtubeChannelRepository; + private final YoutubeClient youtubeClient; + private final KakaoMapClient kakaoMapClient; + + /* + 1. 유튜브 채널 정보를 모두 가져온다. + 2. 마지막 비디오와, 유튜브 UUID를 이용하여 비디오 정보를 가져온다. + 3. 마지막 비디오 UUID를 업데이트 한다. + 4. 카카오 API를 호출해 장소 정보를 가져온다 + */ + public void 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(); + } + } +} + diff --git a/src/main/java/team7/inplace/crawling/client/KakaoMapClient.java b/src/main/java/team7/inplace/crawling/client/KakaoMapClient.java new file mode 100644 index 00000000..992f5cdb --- /dev/null +++ b/src/main/java/team7/inplace/crawling/client/KakaoMapClient.java @@ -0,0 +1,58 @@ +package team7.inplace.crawling.client; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +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.global.kakao.config.KakaoApiProperties; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoMapClient { + private static final String KAKAO_MAP_LOCATE_SEARCH_URL = "https://dapi.kakao.com/v2/local/search/keyword.json"; + private static final String KAKAO_MAP_LOCATE_SEARCH_PARAMS = "?query=%s&sort=accuracy&page=1&size=15"; + private static final String KAKAO_MAP_PLACE_SEARCH_URL = "https://place.map.kakao.com/main/v/"; + private final KakaoApiProperties kakaoApiProperties; + private final RestTemplate restTemplate; + + public RawPlace.Info search(RawVideoInfo videoInfo, String category) { + var address = videoInfo.address(); + var locationInfo = getLocateInfo(address, category); + var placeId = locationInfo.has("documents") ? + locationInfo.get("documents").get(0).get("id").asText() : null; + if (Objects.isNull(placeId)) { + return null; + } + + var placeInfo = getPlaceInfo(placeId); + return RawPlace.Info.from(locationInfo, placeInfo); + } + + private JsonNode getLocateInfo(String address, String category) { + var url = KAKAO_MAP_LOCATE_SEARCH_URL + KAKAO_MAP_LOCATE_SEARCH_PARAMS.formatted(address); + url = url + (Objects.isNull(category) ? "" : "&category_group_code=" + category); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", kakaoApiProperties.getAuthorization()); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, JsonNode.class); + return response.getBody(); + } + + private JsonNode getPlaceInfo(String placeId) { + var url = KAKAO_MAP_PLACE_SEARCH_URL + placeId; + + ResponseEntity response = restTemplate.getForEntity(url, JsonNode.class); + return response.getBody(); + } +} diff --git a/src/main/java/team7/inplace/crawling/client/YoutubeClient.java b/src/main/java/team7/inplace/crawling/client/YoutubeClient.java new file mode 100644 index 00000000..94e86359 --- /dev/null +++ b/src/main/java/team7/inplace/crawling/client/YoutubeClient.java @@ -0,0 +1,96 @@ +package team7.inplace.crawling.client; + +import com.fasterxml.jackson.databind.JsonNode; +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<>(); + String nextPageToken = null; + while (true) { + String url = PLAY_LIST_ITEMS_BASE_URL + String.format(PLAY_LIST_PARAMS, playListId, apiKey); + + JsonNode response = null; + if (Objects.nonNull(nextPageToken)) { + url += "&pageToken=" + nextPageToken; + } + try { + 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)) { + log.error("Youtube API Response가 NULL입니다 {}.", playListId); + break; + } + + var containsLastVideo = extractRawVideoInfo(videoInfos, response.path("items"), finalVideoUUID); + if (containsLastVideo) { + break; + } + nextPageToken = response.path("nextPageToken").asText(); + if (isLastPage(nextPageToken)) { + break; + } + } + return videoInfos; + } + + private boolean isLastPage(String nextPageToken) { + return Objects.isNull(nextPageToken) || nextPageToken.isEmpty(); + } + + private boolean extractRawVideoInfo(List videoInfos, 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); + } + 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/RawPlace.java b/src/main/java/team7/inplace/crawling/client/dto/RawPlace.java new file mode 100644 index 00000000..feb9f7ad --- /dev/null +++ b/src/main/java/team7/inplace/crawling/client/dto/RawPlace.java @@ -0,0 +1,138 @@ +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 new file mode 100644 index 00000000..0faedd74 --- /dev/null +++ b/src/main/java/team7/inplace/crawling/client/dto/RawVideoInfo.java @@ -0,0 +1,8 @@ +package team7.inplace.crawling.client.dto; + +public record RawVideoInfo( + String videoId, + String videoTitle, + String address +) { +} diff --git a/src/main/java/team7/inplace/crawling/domain/ChannelType.java b/src/main/java/team7/inplace/crawling/domain/ChannelType.java new file mode 100644 index 00000000..1a4b47b1 --- /dev/null +++ b/src/main/java/team7/inplace/crawling/domain/ChannelType.java @@ -0,0 +1,15 @@ +package team7.inplace.crawling.domain; + +public enum ChannelType { + FOOD("FD6"); + + private final String code; + + ChannelType(String code) { + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/src/main/java/team7/inplace/crawling/domain/YoutubeChannel.java b/src/main/java/team7/inplace/crawling/domain/YoutubeChannel.java new file mode 100644 index 00000000..00f94f9e --- /dev/null +++ b/src/main/java/team7/inplace/crawling/domain/YoutubeChannel.java @@ -0,0 +1,34 @@ +package team7.inplace.crawling.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity(name = "youtube_channel") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class YoutubeChannel { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long influencerId; + + private String channelUUID; + private String playListUUID; + private String lastVideoUUID; + + private ChannelType channelType; + + public String getChannelTypeCode() { + return channelType.getCode(); + } + + public void updateLastVideoUUID(String lastVideoUUID) { + this.lastVideoUUID = lastVideoUUID; + } +} \ No newline at end of file diff --git a/src/main/java/team7/inplace/crawling/persistence/YoutubeChannelRepository.java b/src/main/java/team7/inplace/crawling/persistence/YoutubeChannelRepository.java new file mode 100644 index 00000000..207016ea --- /dev/null +++ b/src/main/java/team7/inplace/crawling/persistence/YoutubeChannelRepository.java @@ -0,0 +1,7 @@ +package team7.inplace.crawling.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import team7.inplace.crawling.domain.YoutubeChannel; + +public interface YoutubeChannelRepository extends JpaRepository { +} diff --git a/src/main/java/team7/inplace/global/exception/InplaceException.java b/src/main/java/team7/inplace/global/exception/InplaceException.java new file mode 100644 index 00000000..9508d222 --- /dev/null +++ b/src/main/java/team7/inplace/global/exception/InplaceException.java @@ -0,0 +1,24 @@ +package team7.inplace.global.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; +import team7.inplace.global.exception.code.ErrorCode; + +@Getter +public class InplaceException extends RuntimeException { + + private final HttpStatus httpStatus; + private final String errorCode; + private final String errorMessage; + + private InplaceException(ErrorCode errorCode) { + super(errorCode.message()); + this.httpStatus = errorCode.httpStatus(); + this.errorCode = errorCode.code(); + this.errorMessage = errorCode.message(); + } + + public static InplaceException of(ErrorCode errorCode) { + return new InplaceException(errorCode); + } +} diff --git a/src/main/java/team7/inplace/global/exception/code/AuthorizationErrorCode.java b/src/main/java/team7/inplace/global/exception/code/AuthorizationErrorCode.java new file mode 100644 index 00000000..640aaccf --- /dev/null +++ b/src/main/java/team7/inplace/global/exception/code/AuthorizationErrorCode.java @@ -0,0 +1,32 @@ +package team7.inplace.global.exception.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum AuthorizationErrorCode implements ErrorCode { + TOKEN_IS_EMPTY(HttpStatus.BAD_REQUEST, "A001", "Token is Empty"), + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "A002", "Invalid Token"), + TOKEN_IS_EXPIRED(HttpStatus.BAD_REQUEST, "A003", "Token is Expired"); + + 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/global/exception/code/ErrorCode.java b/src/main/java/team7/inplace/global/exception/code/ErrorCode.java new file mode 100644 index 00000000..71f91bfc --- /dev/null +++ b/src/main/java/team7/inplace/global/exception/code/ErrorCode.java @@ -0,0 +1,12 @@ +package team7.inplace.global.exception.code; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + HttpStatus httpStatus(); + + String code(); + + String message(); +} diff --git a/src/main/java/team7/inplace/global/kakao/config/KakaoApiProperties.java b/src/main/java/team7/inplace/global/kakao/config/KakaoApiProperties.java new file mode 100644 index 00000000..78ec6272 --- /dev/null +++ b/src/main/java/team7/inplace/global/kakao/config/KakaoApiProperties.java @@ -0,0 +1,12 @@ +package team7.inplace.global.kakao.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "kakao.api") +public record KakaoApiProperties( + String key +) { + public String getAuthorization() { + return "KakaoAK " + key; + } +} diff --git a/src/main/java/team7/inplace/global/rest/RestTemplateConfig.java b/src/main/java/team7/inplace/global/rest/RestTemplateConfig.java new file mode 100644 index 00000000..f863cbd7 --- /dev/null +++ b/src/main/java/team7/inplace/global/rest/RestTemplateConfig.java @@ -0,0 +1,13 @@ +package team7.inplace.global.rest; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/team7/inplace/influencer/application/InfluencerService.java b/src/main/java/team7/inplace/influencer/application/InfluencerService.java new file mode 100644 index 00000000..ca1c0c0b --- /dev/null +++ b/src/main/java/team7/inplace/influencer/application/InfluencerService.java @@ -0,0 +1,22 @@ +package team7.inplace.influencer.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import team7.inplace.influencer.application.dto.InfluencerInfo; +import team7.inplace.influencer.persistence.InfluencerRepository; + +@RequiredArgsConstructor +@Service +public class InfluencerService { + + private final InfluencerRepository influencerRepository; + + @Transactional(readOnly = true) + public List getAllInfluencers() { + return influencerRepository.findAll().stream() + .map(InfluencerInfo::from) + .toList(); + } +} diff --git a/src/main/java/team7/inplace/influencer/application/dto/InfluencerInfo.java b/src/main/java/team7/inplace/influencer/application/dto/InfluencerInfo.java new file mode 100644 index 00000000..b5e300ab --- /dev/null +++ b/src/main/java/team7/inplace/influencer/application/dto/InfluencerInfo.java @@ -0,0 +1,22 @@ +package team7.inplace.influencer.application.dto; + +import team7.inplace.influencer.domain.Influencer; + +public record InfluencerInfo( + Long influencerId, + String influencerName, + String influencerImgUrl, + String influencerJob, + boolean likes +) { + + public static InfluencerInfo from(Influencer influencer) { + return new InfluencerInfo( + influencer.getId(), + influencer.getName(), + influencer.getImgUrl(), + influencer.getJob(), + false // 좋아요 기능 추가할 때 로직 추가 예정 + ); + } +} diff --git a/src/main/java/team7/inplace/influencer/config/InfluencerDataLoader.java b/src/main/java/team7/inplace/influencer/config/InfluencerDataLoader.java new file mode 100644 index 00000000..0337d27c --- /dev/null +++ b/src/main/java/team7/inplace/influencer/config/InfluencerDataLoader.java @@ -0,0 +1,26 @@ +package team7.inplace.influencer.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import team7.inplace.influencer.domain.Influencer; +import team7.inplace.influencer.persistence.InfluencerRepository; + +@RequiredArgsConstructor +@Component +public class InfluencerDataLoader implements ApplicationRunner { + + private final InfluencerRepository influencerRepository; + + @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"); + + influencerRepository.save(influencer1); + influencerRepository.save(influencer2); + influencerRepository.save(influencer3); + } +} diff --git a/src/main/java/team7/inplace/influencer/domain/Influencer.java b/src/main/java/team7/inplace/influencer/domain/Influencer.java new file mode 100644 index 00000000..ed87c896 --- /dev/null +++ b/src/main/java/team7/inplace/influencer/domain/Influencer.java @@ -0,0 +1,38 @@ +package team7.inplace.influencer.domain; + +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class Influencer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 30) + private String name; + + @Column(nullable = false, length = 20) + private String job; + + @Column(nullable = false, columnDefinition = "TEXT") + private String imgUrl; + + @Builder + public Influencer(String name, String job, String imgUrl) { + this.name = name; + this.job = job; + this.imgUrl = imgUrl; + } +} diff --git a/src/main/java/team7/inplace/video/repository/InfluencerRepository.java b/src/main/java/team7/inplace/influencer/persistence/InfluencerRepository.java similarity index 70% rename from src/main/java/team7/inplace/video/repository/InfluencerRepository.java rename to src/main/java/team7/inplace/influencer/persistence/InfluencerRepository.java index 7d7d12f6..a1a12820 100644 --- a/src/main/java/team7/inplace/video/repository/InfluencerRepository.java +++ b/src/main/java/team7/inplace/influencer/persistence/InfluencerRepository.java @@ -1,12 +1,10 @@ -package team7.inplace.video.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import team7.inplace.video.entity.Influencer; +package team7.inplace.influencer.persistence; import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import team7.inplace.influencer.domain.Influencer; public interface InfluencerRepository extends JpaRepository { - // 더미 데이터 입니다!! @Override List findAllById(Iterable longs); diff --git a/src/main/java/team7/inplace/influencer/presentation/InfluencerController.java b/src/main/java/team7/inplace/influencer/presentation/InfluencerController.java new file mode 100644 index 00000000..dbe86ed4 --- /dev/null +++ b/src/main/java/team7/inplace/influencer/presentation/InfluencerController.java @@ -0,0 +1,31 @@ +package team7.inplace.influencer.presentation; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import team7.inplace.influencer.application.InfluencerService; +import team7.inplace.influencer.application.dto.InfluencerInfo; +import team7.inplace.influencer.presentation.dto.InfluencerListResponse; +import team7.inplace.influencer.presentation.dto.InfluencerResponse; + +@RequiredArgsConstructor +@RestController +public class InfluencerController { + + private final InfluencerService influencerService; + + @GetMapping("/influencers") + public ResponseEntity getAllInfluencers() { + List influencersDtoList = influencerService.getAllInfluencers(); + List influencers = influencersDtoList.stream() + .map(InfluencerResponse::from) + .toList(); + InfluencerListResponse response = new InfluencerListResponse(influencers); + + return new ResponseEntity<>(response, HttpStatus.OK); + } + +} diff --git a/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerListResponse.java b/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerListResponse.java new file mode 100644 index 00000000..56de7a65 --- /dev/null +++ b/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerListResponse.java @@ -0,0 +1,9 @@ +package team7.inplace.influencer.presentation.dto; + +import java.util.List; + +public record InfluencerListResponse( + List influencers +) { + +} diff --git a/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerResponse.java b/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerResponse.java new file mode 100644 index 00000000..12729aad --- /dev/null +++ b/src/main/java/team7/inplace/influencer/presentation/dto/InfluencerResponse.java @@ -0,0 +1,22 @@ +package team7.inplace.influencer.presentation.dto; + +import team7.inplace.influencer.application.dto.InfluencerInfo; + +public record InfluencerResponse( + Long influencerId, + String influencerName, + String influencerImgUrl, + String influencerJob, + boolean likes +) { + + public static InfluencerResponse from(InfluencerInfo influencerInfo) { + return new InfluencerResponse( + influencerInfo.influencerId(), + influencerInfo.influencerName(), + influencerInfo.influencerImgUrl(), + influencerInfo.influencerJob(), + influencerInfo.likes() + ); + } +} diff --git a/src/main/java/team7/inplace/place/application/CategoryService.java b/src/main/java/team7/inplace/place/application/CategoryService.java new file mode 100644 index 00000000..e3103751 --- /dev/null +++ b/src/main/java/team7/inplace/place/application/CategoryService.java @@ -0,0 +1,21 @@ +package team7.inplace.place.application; + +import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import team7.inplace.place.application.dto.CategoryInfo; +import team7.inplace.place.domain.Category; +import team7.inplace.place.persistence.PlaceRepository; + +@Service +@RequiredArgsConstructor +public class CategoryService { + + private final PlaceRepository placeRepository; + + public List getCategories() { + return Arrays.stream(Category.values()).map(category -> new CategoryInfo(category.name())) + .toList(); + } +} diff --git a/src/main/java/team7/inplace/place/application/PlaceService.java b/src/main/java/team7/inplace/place/application/PlaceService.java new file mode 100644 index 00000000..1fc5f560 --- /dev/null +++ b/src/main/java/team7/inplace/place/application/PlaceService.java @@ -0,0 +1,89 @@ +package team7.inplace.place.application; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +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.PlacesCoordinateCommand; +import team7.inplace.place.application.command.PlacesCommand.PlacesFilterParamsCommand; +import team7.inplace.place.application.dto.PlaceInfo; +import team7.inplace.place.domain.Place; +import team7.inplace.place.persistence.PlaceRepository; +import team7.inplace.video.domain.Video; +import team7.inplace.video.persistence.VideoRepository; + +@Service +@RequiredArgsConstructor +public class PlaceService { + + private final PlaceRepository placeRepository; + + private final VideoRepository videoRepository; + + public Page getPlacesWithinRadius( + PlacesCoordinateCommand placesCoordinateCommand, + PlacesFilterParamsCommand placesFilterParamsCommand) { + + // categories와 influencers 필터 처리 + List categoryFilters = null; + List influencerFilters = null; + + // 필터 값이 있을 경우에만 split 처리 + if (placesFilterParamsCommand.isCategoryFilterExists()) { + categoryFilters = Arrays.stream(placesFilterParamsCommand.categories().split(",")) + .toList(); + } + + if (placesFilterParamsCommand.isInfluencerFilterExists()) { + influencerFilters = Arrays.stream(placesFilterParamsCommand.influencers().split(",")) + .toList(); + } + + // 주어진 좌표로 장소를 찾고, 해당 페이지의 결과를 가져옵니다. + Page placesPage = getPlacesByDistance(placesCoordinateCommand, categoryFilters, + influencerFilters); + + // Place ID 목록 추출 + List placeIds = placesPage.getContent().stream() + .map(Place::getId) + .toList(); + + // influencer 조회와 PlaceInfo 변환 + List