diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index b2a5fe8..65949d9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -35,6 +35,13 @@ jobs: - name: Build with Gradle Wrapper run: ./gradlew clean build + - name: Upload test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test + - name: Upload build artifact # only cd.yml uses: actions/upload-artifact@v4 with: @@ -71,6 +78,7 @@ jobs: key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} script: | cd ${{ secrets.NCP_SERVER_PATH }} + chmod -R 755 log # 실행 중인 애플리케이션 중지 및 중지 실패 시 action 중단 방지 if [ -f pid.file ]; then kill $(cat pid.file) || true @@ -80,7 +88,7 @@ jobs: # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 TIMESTAMP=$(date +%Y%m%d_%H%M%S) - nohup java -jar -Dspring.profiles.active=dev *-SNAPSHOT.jar > dev_${TIMESTAMP}.log 2>&1 & echo $! > pid.file + nohup java -jar -Dspring.profiles.active=dev *-SNAPSHOT.jar > /dev/null 2>&1 & echo $! > pid.file echo "Development server deploy done." # product @@ -112,6 +120,7 @@ jobs: key: ${{ secrets.NCP_SSH_PRIVATE_KEY }} script: | cd ${{ secrets.NCP_SERVER_PATH }} + chmod -R 755 log # 실행 중인 애플리케이션 중지 및 중지 실패 시 action 중단 방지 if [ -f pid.file ]; then kill $(cat pid.file) || true @@ -120,6 +129,5 @@ jobs: fi # 새 애플리케이션 실행 - 일단 임시로 와일드 카드 사용 - TIMESTAMP=$(date +%Y%m%d_%H%M%S) - nohup java -jar -Dspring.profiles.active=prod *-SNAPSHOT.jar > prod_${TIMESTAMP}.log 2>&1 & echo $! > pid.file + nohup java -jar -Dspring.profiles.active=prod *-SNAPSHOT.jar > /prod/null 2>&1 & echo $! > pid.file echo "Production server deploy done." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f635728..e17d102 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,21 @@ jobs: java-version: '17' distribution: 'temurin' - # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. + - name: Start Elasticsearch & Kibana + run: | + docker compose up -d + timeout 120s bash -c ' + until [[ "$(curl -s http://localhost:9200/_cluster/health | jq -r ".status" 2>/dev/null)" =~ ^(green|yellow)$ ]]; do + echo "Waiting for Elasticsearch" + sleep 5 + done + echo "-- ES health is ok " + ' + + - name: Check ES container logs + run: docker compose logs --tail=50 elasticsearch || true + + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -41,3 +55,10 @@ jobs: - name: Build with Gradle Wrapper run: ./gradlew clean build + + - name: Upload test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test diff --git a/.gitignore b/.gitignore index 235eb73..69d0cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,5 @@ out/ ### properties ### application.properties -application-local.properties application-dev.properties application-prod.properties \ No newline at end of file diff --git a/build.gradle b/build.gradle index aa16805..7151023 100644 --- a/build.gradle +++ b/build.gradle @@ -36,8 +36,38 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // Swagger implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.9' + // Spring Cloud AWS + implementation "io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.1" // S3 implementation group: 'com.amazonaws', name: 'aws-java-sdk-s3', version: '1.12.787' + + //jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Milvus Java SDK + implementation group: 'io.milvus', name: 'milvus-sdk-java', version: '2.5.10' + // Flyway + implementation group: 'org.flywaydb', name: 'flyway-mysql', version: '11.10.2' + implementation 'org.flywaydb:flyway-core' + + + // elastic search + implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' + + //Spring Batch + implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'org.springframework.batch:spring-batch-core' + testImplementation 'org.springframework.batch:spring-batch-test' + implementation 'jakarta.persistence:jakarta.persistence-api' + + // Tika + implementation 'org.apache.tika:tika-core:3.1.0' + + //로그인 관련 + implementation 'org.apache.commons:commons-lang3:3.14.0' + } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f35aac7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +version: '3.8' +services: + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.6.0 + container_name: elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.security.http.ssl.enabled=false + + command: > + bash -c " + if [ ! -d '/usr/share/elasticsearch/plugins/analysis-nori' ]; then + elasticsearch-plugin install --batch analysis-nori; + fi && + /usr/local/bin/docker-entrypoint.sh eswrapper + " + + ports: + - "9200:9200" + - "9300:9300" + + extra_hosts: + - "elasticsearch:127.0.0.1" + volumes: + - esdata:/usr/share/elasticsearch/data + networks: + - es_network + + kibana: + image: docker.elastic.co/kibana/kibana:8.6.0 + container_name: kibana + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + ports: + - "5601:5601" + depends_on: + - elasticsearch + networks: + - es_network + +networks: + es_network: + driver: bridge + +volumes: + esdata: + driver: local \ No newline at end of file diff --git a/src/main/java/com/batch/client/MigrationClient.java b/src/main/java/com/batch/client/MigrationClient.java new file mode 100644 index 0000000..a1ff38b --- /dev/null +++ b/src/main/java/com/batch/client/MigrationClient.java @@ -0,0 +1,136 @@ +package com.batch.client; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import com.batch.dto.AreaBasedListResponse; +import com.batch.dto.CategoryCodeResponse; +import com.batch.dto.ContentTypeId; +import com.batch.dto.DetailImageResponse; +import com.batch.dto.DetailInfoResponse; + +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class MigrationClient { + + @Value("${tour.api.base-url}") + private String baseUrl; + + @Value("${tour.api.service-key}") + private String serviceKey; + + private RestClient restClient; + + public MigrationClient(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder.build(); + } + + private static void handle(HttpRequest request, ClientHttpResponse response) throws IOException { + // 5xx 서버 에러 처리 + String errorBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); + log.error("에러 응답 areaBasedList API ({} {}): Status {}, Body: {}", + request.getMethod(), request.getURI(), response.getStatusCode(), errorBody); + throw new RuntimeException("AreaBasedList API 호출 오류 : " + errorBody); + } + + // areaBasedList + public AreaBasedListResponse getAreaBasedLists(int pageNo, int numOfRows, String contentTypeId, String modifiedTime) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUrl + "/areaBasedList") + .queryParam("serviceKey", serviceKey) + .queryParam("pageNo", pageNo) + .queryParam("numOfRows", numOfRows) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "CatsGoToGedog") + .queryParam("contentTypeId", contentTypeId) // 관광타입(12:관광지, 14:문화시설, 15:축제공연행사, 28:레포츠, 32:숙박, 38:쇼핑, 39:음식점) ID + .queryParam("_type", "json"); + + if(modifiedTime != null && !modifiedTime.isEmpty()) { + uri.queryParam("modifiedtime", modifiedTime); + } + URI fullUri = uri.encode(StandardCharsets.UTF_8).build().toUri(); + + log.info("API Request URL (areaBasedList) : {}", uri.toUriString()); + + try { + return restClient.get() + .uri(fullUri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatus.BAD_REQUEST::equals, (request, response) -> { + String errorBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); + log.error("에러 응답 areaBasedList API ({} {}): Status {}, Body {}",request.getMethod(), request.getURI(), response.getStatusCode(), errorBody); + }) + .onStatus(HttpStatusCode::is4xxClientError, MigrationClient::handle) + .onStatus(HttpStatusCode::is5xxServerError, MigrationClient::handle) + .body(AreaBasedListResponse.class); + }catch (RuntimeException e) { + log.error("areaBasedList API 요청중 오류 발생 : {}", e.getMessage(), e); + throw e; // 배치 스탭에서 재시도/스킵 위해 재throw + }catch (Exception e) {// 기타 exception + log.error("areaBasedList API 요청중 예상치 못한 오류 발생: {}", e.getMessage(), e); + throw new RuntimeException("areaBasedList API 요청중 예상치 못한 오류 발생", e); + } + } + + public AreaBasedListResponse getAreaBasedLists(int pageNo, int numOfRows, String contentTypeId) { + return getAreaBasedLists(pageNo, numOfRows, contentTypeId, null); + } + + // categoryCode + public List getCategoryCode(String contentTypeId, String cat1, String cat2) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUrl + "/categoryCode") + .queryParam("serviceKey", serviceKey) + .queryParam("pageNo", 1) + .queryParam("numOfRows", 100) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "CatsGoToGedog") + .queryParam("contentTypeId", contentTypeId) // 관광타입(12:관광지, 14:문화시설, 15:축제공연행사, 28:레포츠, 32:숙박, 38:쇼핑, 39:음식점) ID + .queryParam("_type", "json"); + if(cat1 != null && !cat1.isEmpty()) { + uri.queryParam("cat1", cat1); + } + if(cat2 != null && !cat2.isEmpty()) { + uri.queryParam("cat2", cat2); + } + URI fullUri = uri.encode(StandardCharsets.UTF_8).build().toUri(); + + try { + CategoryCodeResponse response = restClient.get() + .uri(fullUri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, MigrationClient::handle) + .body(CategoryCodeResponse.class); + return Optional.ofNullable(response) + .map(CategoryCodeResponse::getResponse) + .map(CategoryCodeResponse.Response::getBody) + .map(CategoryCodeResponse.Body::getItems) + .map(CategoryCodeResponse.Items::getItem) + .orElse(Collections.emptyList()); + } catch (RuntimeException e) { + log.error("categoryCode API 요청 중 오류 발생 (contentTypeId: {}, cat1: {}, cat2: {}): {}", + contentTypeId, cat1, cat2, e.getMessage(), e); + return Collections.emptyList(); + } catch (Exception e) { + log.error("categoryCode API 요청 중 예상치 못한 오류 발생 (contentTypeId: {}, cat1: {}, cat2: {}): {}", + contentTypeId, cat1, cat2, e.getMessage(), e); + return Collections.emptyList(); + } + } +} diff --git a/src/main/java/com/batch/config/BatchConfig.java b/src/main/java/com/batch/config/BatchConfig.java new file mode 100644 index 0000000..d54acc0 --- /dev/null +++ b/src/main/java/com/batch/config/BatchConfig.java @@ -0,0 +1,279 @@ +package com.batch.config; + +import java.util.List; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.item.database.JpaCursorItemReader; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.client.ResourceAccessException; + +import com.batch.dto.AreaBasedListResponse; +import com.batch.dto.DetailInfoProcessResult; +import com.batch.dto.DetailIntroProcessResult; +import com.batch.listener.CustomJobExecutionListener; +import com.batch.listener.CustomSkipListener; +import com.batch.listener.CustomStepExecutionListener; +import com.batch.processor.AreaBasedListItemProcessor; +import com.batch.processor.DetailCommonProcessor; +import com.batch.processor.DetailImageProcessor; +import com.batch.processor.DetailInfoProcessor; +import com.batch.processor.DetailIntroProcessor; +import com.batch.processor.DetailPetTourProcessor; +import com.batch.reader.AreaBasedListApiReader; +import com.batch.tasklet.CategoryFetchTasklet; +import com.batch.writer.ContentImageWriter; +import com.batch.writer.DetailCommonWriter; +import com.batch.writer.DetailInfoWriter; +import com.batch.writer.DetailIntroWriter; +import com.batch.writer.DetailPetTourWriter; +import com.batch.writer.ItemWriterConfig; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; + +import jakarta.persistence.EntityManagerFactory; + +@Configuration +@Slf4j +@RequiredArgsConstructor +public class BatchConfig { + + private final EntityManagerFactory entityManagerFactory; + private final PlatformTransactionManager transactionManager; + private final JobRepository jobRepository; + private final CategoryFetchTasklet categoryFetchTasklet; + + // Listener + private final CustomJobExecutionListener customJobExecutionListener; + private final CustomStepExecutionListener customStepExecutionListener; + private final CustomSkipListener customSkipListener; + + // Reader + private final JpaPagingItemReader detailImageContentReader; + private final AreaBasedListApiReader contentReader; + private final JpaPagingItemReader detailCommonItemReader; + private final JpaPagingItemReader detailPetTourItemReader; + private final JpaPagingItemReader detailInfoItemReader; + private final JpaCursorItemReader sightsInformationItemReader; + private final JpaCursorItemReader lodgeInformationItemReader; + private final JpaCursorItemReader festivalInformationItemReader; + private final JpaCursorItemReader restaurantInformationItemReader; + + // Writer + private final ItemWriterConfig itemWriterConfig; + private final ContentImageWriter contentImageWriter; + private final DetailCommonWriter detailCommonWriter; + private final DetailPetTourWriter detailPetTourWriter; + private final DetailInfoWriter detailInfoWriter; + private final DetailIntroWriter detailIntroWriter; + + // Processor + private final DetailImageProcessor detailImageProcessor; + private final AreaBasedListItemProcessor contentProcessor; + private final DetailCommonProcessor detailCommonProcessor; + private final DetailPetTourProcessor detailPetTourProcessor; + private final DetailInfoProcessor detailInfoProcessor; + private final DetailIntroProcessor detailIntroProcessor; + + private final int CHUNK_SIZE = 100; + + + // 메인 JOB 컨텐츠 > 이미지 > 디테일 + @Bean + public Job contentBatchJob() { + log.info("Configuring contentBatchJob..."); + return new JobBuilder("contentBatchJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(customJobExecutionListener) + .start(contentDataFetchStep()) + .next(detailCommonFetchStep()) + .next(detailImageFetchStep()) + .next(petGuideFetchStep()) + .next(detailInfoFetchStep()) + .next(detailIntroSightsFetchStep()) + .next(detailIntroLodgeFetchStep()) + .next(detailIntroRestaurantFetchStep()) + .next(detailIntroFestivalFetchStep()) + .build(); + } + + // content step + @Bean + public Step contentDataFetchStep() { + log.info("Configuring contentDataFetchStep..."); + return new StepBuilder("contentDataFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(contentReader) + .processor(contentProcessor) + .writer(itemWriterConfig.step1ContentWriter(entityManagerFactory)) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + + // category Step + @Bean + public Step categoryCodeFetchStep() { + log.info("Configuring categoryCodeFetchStep..."); + return new StepBuilder("categoryCodeFetchStep", jobRepository) + .tasklet(categoryFetchTasklet, transactionManager) + .listener(customStepExecutionListener) + .build(); + } + + // category Job + @Bean + public Job categoryCodeBatchJob() { + log.info("Configuring categoryCodeBatchJob..."); + return new JobBuilder("categoryCodeBatchJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(customJobExecutionListener) + .start(categoryCodeFetchStep()) + .build(); + } + + // 이미지 스텝 + @Bean + public Step detailImageFetchStep() { + return new StepBuilder("detailImageFetchStep", jobRepository) + .>chunk(CHUNK_SIZE, transactionManager) + .reader(detailImageContentReader) + .processor(detailImageProcessor) + .writer(contentImageWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + + // content overview 스텝 + @Bean + public Step detailCommonFetchStep() { + return new StepBuilder("detailCommonFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(detailCommonItemReader) + .processor(detailCommonProcessor) + .writer(detailCommonWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + + // PetGuide 스텝 + @Bean + public Step petGuideFetchStep() { + return new StepBuilder("petGuideFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(detailPetTourItemReader) + .processor(detailPetTourProcessor) + .writer(detailPetTourWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + + // detailInfo 스텝 + @Bean + public Step detailInfoFetchStep() { + return new StepBuilder("detailInfoFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(detailInfoItemReader) + .processor(detailInfoProcessor) + .writer(detailInfoWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + + // detailIntro 스텝 + @Bean + public Step detailIntroSightsFetchStep() { + return new StepBuilder("detailIntroSightsFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sightsInformationItemReader) + .processor(detailIntroProcessor) + .writer(detailIntroWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + // lodge 청크이슈로 1로 설정 + @Bean + public Step detailIntroLodgeFetchStep() { + return new StepBuilder("detailIntroLodgeFetchStep", jobRepository) + .chunk(1, transactionManager) + .reader(lodgeInformationItemReader) + .processor(detailIntroProcessor) + .writer(detailIntroWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + @Bean + public Step detailIntroRestaurantFetchStep() { + return new StepBuilder("detailIntroRestaurantFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(restaurantInformationItemReader) + .processor(detailIntroProcessor) + .writer(detailIntroWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } + @Bean + public Step detailIntroFestivalFetchStep() { + return new StepBuilder("detailIntroFestivalFetchStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(festivalInformationItemReader) + .processor(detailIntroProcessor) + .writer(detailIntroWriter) + .listener(customStepExecutionListener) + .faultTolerant() + .skipLimit(2000) + .skip(Exception.class) + .retry(ResourceAccessException.class) + .listener(customSkipListener) + .build(); + } +} diff --git a/src/main/java/com/batch/config/ItemsDeserializer.java b/src/main/java/com/batch/config/ItemsDeserializer.java new file mode 100644 index 0000000..d4d716a --- /dev/null +++ b/src/main/java/com/batch/config/ItemsDeserializer.java @@ -0,0 +1,47 @@ +package com.batch.config; + +import java.io.IOException; + +import com.batch.dto.AreaBasedListResponse; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; + +public class ItemsDeserializer extends JsonDeserializer implements ContextualDeserializer { + + private JavaType targetType; + + public ItemsDeserializer() { + } + + public ItemsDeserializer(JavaType targetType) { + this.targetType = targetType; + } + + @Override + public AreaBasedListResponse.Items deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.readValueAsTree(); + + if(node.isTextual() && node.textValue().isEmpty()) { + return null; + } + + ObjectMapper mapper = (ObjectMapper) p.getCodec(); + return mapper.treeToValue(node, targetType); + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException { + if(property != null) { + JavaType type = property.getType(); + return new ItemsDeserializer(type); + } + return this; + } +} diff --git a/src/main/java/com/batch/dto/AreaBasedListResponse.java b/src/main/java/com/batch/dto/AreaBasedListResponse.java new file mode 100644 index 0000000..5a01d9b --- /dev/null +++ b/src/main/java/com/batch/dto/AreaBasedListResponse.java @@ -0,0 +1,53 @@ +package com.batch.dto; + +import java.util.List; + +import com.batch.config.ItemsDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/areaBasedList) +public record AreaBasedListResponse (Response response) { + + public record Response( + Header header, + Body body + ) {} + + public record Header( + String resultCode, + String resultMsg + ) {} + + public record Body( + @JsonDeserialize(using = ItemsDeserializer.class) + Items items, + int numOfRows, + int pageNo, + int totalCount + ) {} + + public record Items(List item) {} + + public record Item( + String addr1, + String addr2, + String areacode, + String cat1, + String cat2, + String cat3, + String contentid, + String contenttypeid, + String createdtime, + String firstimage, + String firstimage2, + String cpyrhtDivCd, // copyright + String mapx, + String mapy, + String mlevel, + String modifiedtime, + String sigungucode, + String tel, + String title, + String zipcode + ) {} +} diff --git a/src/main/java/com/batch/dto/AreaCodeResponse.java b/src/main/java/com/batch/dto/AreaCodeResponse.java new file mode 100644 index 0000000..52cad56 --- /dev/null +++ b/src/main/java/com/batch/dto/AreaCodeResponse.java @@ -0,0 +1,43 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import lombok.Data; + +@Data +public class AreaCodeResponse { + private Response response; + + @Data + public static class Response { + private Header header; + private Body body; + } + + @Data + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Data + public static class Body { + private Items items; + private int numOfRows; + private int pageNo; + private int totalCount; + } + + @Data + public static class Items { + private List item = Collections.emptyList(); + } + + @Data + public static class Item { + private String code; // 지역 코드 (areaCode) + private String name; // 지역 한글명 + private String rnum; + } +} diff --git a/src/main/java/com/batch/dto/CategoryCodeResponse.java b/src/main/java/com/batch/dto/CategoryCodeResponse.java new file mode 100644 index 0000000..5e47f50 --- /dev/null +++ b/src/main/java/com/batch/dto/CategoryCodeResponse.java @@ -0,0 +1,46 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import lombok.Data; + +@Data +public class CategoryCodeResponse { + private Response response; + + @Data + public static class Response { + private Header header; + private Body body; + } + + @Data + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Data + public static class Body { + private Items items; + private int numOfRows; + private int pageNo; + private int totalCount; + } + + @Data + public static class Items { + private List item = Collections.emptyList(); + } + + @Data + public static class Item { + private String name; + private String rnum; + private String code; + private String cat1; + private String cat2; + private String cat3; + } +} diff --git a/src/main/java/com/batch/dto/ContentTypeId.java b/src/main/java/com/batch/dto/ContentTypeId.java new file mode 100644 index 0000000..b8cc599 --- /dev/null +++ b/src/main/java/com/batch/dto/ContentTypeId.java @@ -0,0 +1,18 @@ +package com.batch.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ContentTypeId { + 관광지(12), + 문화시설(14), + 축제공연행사(15), + 레포츠(28), + 숙박(32), + 쇼핑(38), + 음식점(39); + + private final int contentTypeId; +} diff --git a/src/main/java/com/batch/dto/DetailCommonResponse.java b/src/main/java/com/batch/dto/DetailCommonResponse.java new file mode 100644 index 0000000..f843561 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailCommonResponse.java @@ -0,0 +1,65 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.io.IOException; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailCommon) +public record DetailCommonResponse(Response response){ + + public record Response( + Header header, + Body body + ) {} + + public record Header( + String resultCode, + String resultMsg + ) {} + + public record Body( + Items items, + int numOfRows, + int pageNo, + int totalCount + ) {} + + public record Items(List item) { + + @JsonCreator + public static Items from(JsonNode node) throws IOException { + + final ObjectMapper mapper = new ObjectMapper(); + + if (node.isTextual() && node.asText().isEmpty()) { + return new Items(Collections.emptyList()); + } + + if (node.isObject()) { + JsonNode itemNode = node.get("item"); + + if (itemNode == null || !itemNode.isArray()) { + return new Items(Collections.emptyList()); + } + + List itemList = mapper.convertValue(itemNode, new TypeReference<>() {}); + return new Items(itemList); + } + + return new Items(Collections.emptyList()); + } + + } + + public record Item( + String contentid, + String contenttypeid, + String overview + ){} +} diff --git a/src/main/java/com/batch/dto/DetailImageResponse.java b/src/main/java/com/batch/dto/DetailImageResponse.java new file mode 100644 index 0000000..c617ada --- /dev/null +++ b/src/main/java/com/batch/dto/DetailImageResponse.java @@ -0,0 +1,76 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import com.batch.config.ItemsDeserializer; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import io.jsonwebtoken.io.IOException; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailImage) +public record DetailImageResponse (Response response){ + + public record Response( + Header header, + Body body + ) {} + + public record Header( + String resultCode, + String resultMsg + ) {} + + public record Body( + Items items, + int numOfRows, + int pageNo, + int totalCount + ) {} + + public record Items(List item) { + + @JsonCreator + public static Items from(JsonNode node) throws IOException { + + final ObjectMapper mapper = new ObjectMapper(); + + // items 필드가 비어있을 경우 + if (node.isTextual() && node.asText().isEmpty()) { + return new Items(Collections.emptyList()); + } + + // 정상적인 JSON 객체일 경우 + if (node.isObject()) { + // "item"이라는 이름의 하위 노드를 찾습니다. + JsonNode itemNode = node.get("item"); + + // 하위 노드가 없을 경우 + if (itemNode == null || !itemNode.isArray()) { + return new Items(Collections.emptyList()); + } + + // itemNode > List + List itemList = mapper.convertValue(itemNode, new TypeReference<>() {}); + return new Items(itemList); + } + + return new Items(Collections.emptyList()); + } + + } + + public record Item( + String cpyrhtDivCd, + String contentid, + String imgname, + String originimgurl, + String serialnum, + String smallimageurl + ){} +} diff --git a/src/main/java/com/batch/dto/DetailInfoAccommodationResponse.java b/src/main/java/com/batch/dto/DetailInfoAccommodationResponse.java new file mode 100644 index 0000000..b18e676 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailInfoAccommodationResponse.java @@ -0,0 +1,66 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import lombok.Data; + +// 반복정보검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailInfo) +@Data +public class DetailInfoAccommodationResponse { + private Response response; + + @Data + public static class Response { + private Header header; + private Body body; + } + + @Data + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Data + public static class Body { + private Items items; + private int numOfRows; + private int pageNo; + private int totalCount; + } + + @Data + public static class Items { + private List item = Collections.emptyList(); + } + + @Data + public static class Item { + private String roominfono; + private String roomtitle; + private String roomsize1; + private String roomcount; + private String roombasecount; + private String roommaxcount; + private String roomoffseasonminfee1; + private String roomoffseasonminfee2; + private String roompeakseasonminfee1; + private String roompeakseasonminfee2; + private String roomintro; + private String roombath; + private String facility; + private String roomhometheater; + private String roomaircondition; + private String roomtv; + private String roompc; + private String roomcable; + private String roominternet; + private String roomrefrigerator; + private String roomtoiletries; + private String roomsofa; + private String roomcook; + private String roomhairdryer; + private String roomtable; + } +} diff --git a/src/main/java/com/batch/dto/DetailInfoApiResponse.java b/src/main/java/com/batch/dto/DetailInfoApiResponse.java new file mode 100644 index 0000000..9559b47 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailInfoApiResponse.java @@ -0,0 +1,43 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.io.IOException; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailInfo) +public record DetailInfoApiResponse(Response response){ + + public record Response(Header header, Body body) {} + public record Header(String resultCode, String resultMsg) {} + + public record Body(JsonNode items, int numOfRows, int pageNo, int totalCount) {} + + public record Items(List item) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record GeneralItem(String contentid, String contentypeid, String serialnum, String infoname, String infotext, String fldgubun) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record RoomItem( + String contentid, String roomtitle, String roomsize1, String roomcount, + String roombasecount, String roommaxcount, String roomintro, + String roombathfacility, String roombath, String roomhometheater, + String roomaircondition, String roomtv, String roompc, String roomcable, + String roominternet, String roomrefrigerator, String roomtoiletries, + String roomsofa, String roomcook, String roomtable, String roomhairdryer, + String roomimg1, String roomimg1alt, String roomimg1cpyrhtdiv, + String roomimg2, String roomimg2alt, String roomimg2cpyrhtdiv, + String roomimg3, String roomimg3alt, String roomimg3cpyrhtdiv, + String roomimg4, String roomimg4alt, String roomimg4cpyrhtdiv, + String roomimg5, String roomimg5alt, String roomimg5cpyrhtdiv, + String roomoffseasonminfee1, String roomoffseasonminfee2, String roompeakseasonminfee1, + String roompeakseasonminfee2, String roomsize2 + ) {} +} diff --git a/src/main/java/com/batch/dto/DetailInfoProcessResult.java b/src/main/java/com/batch/dto/DetailInfoProcessResult.java new file mode 100644 index 0000000..9bb8042 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailInfoProcessResult.java @@ -0,0 +1,17 @@ +package com.batch.dto; + +import java.util.List; + +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoom; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class DetailInfoProcessResult { + private final List generalInfoList; + private final List roomInfoList; + +} diff --git a/src/main/java/com/batch/dto/DetailInfoResponse.java b/src/main/java/com/batch/dto/DetailInfoResponse.java new file mode 100644 index 0000000..77e909b --- /dev/null +++ b/src/main/java/com/batch/dto/DetailInfoResponse.java @@ -0,0 +1,46 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import lombok.Data; + +@Data +public class DetailInfoResponse { + private Response response; + + @Data + public static class Response { + private Header header; + private Body body; + } + + @Data + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Data + public static class Body { + private Items items; + private int numOfRows; + private int pageNo; + private int totalCount; + } + + @Data + public static class Items { + private List item = Collections.emptyList(); + } + + @Data + public static class Item { + private String contentid; + private String contenttypeid; + private String fldgubun; + private String infoname; + private String infotext; + private String serialnum; + } +} diff --git a/src/main/java/com/batch/dto/DetailIntroProcessResult.java b/src/main/java/com/batch/dto/DetailIntroProcessResult.java new file mode 100644 index 0000000..4864951 --- /dev/null +++ b/src/main/java/com/batch/dto/DetailIntroProcessResult.java @@ -0,0 +1,21 @@ +package com.batch.dto; + +import java.util.List; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class DetailIntroProcessResult { + private final List sightsInfoList; + private final List lodgeInfoList; + private final List restaurantInfoList; + private final List festivalInformationList; + +} diff --git a/src/main/java/com/batch/dto/DetailIntroResponse.java b/src/main/java/com/batch/dto/DetailIntroResponse.java new file mode 100644 index 0000000..a7f50ce --- /dev/null +++ b/src/main/java/com/batch/dto/DetailIntroResponse.java @@ -0,0 +1,76 @@ +package com.batch.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailIntro) +public record DetailIntroResponse(Response response){ + + public record Response(Header header, Body body) {} + public record Header(String resultCode, String resultMsg) {} + + public record Body(JsonNode items, int numOfRows, int pageNo, int totalCount) {} + + public record Items(List item) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SightsItem( + String contentid, String contenttypeid, + String heritage1, String heritage2, + String heritage3, String infocenter, + String opendate, String restdate, + String expguide, String expagerange, + String accomcount, String useseason, + String usetime, String parking, + String chkcreditcard + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record FestivalItem( + String contentid, String contenttypeid, + String sponsor1, String sponsor1tel, + String sponsor2, String sponsor2tel, + String eventenddate, String playtime, + String eventplace, String eventhomepage, + String agelimit, String bookingplace, + String placeinfo, String subevent, + String program, String eventstartdate, + String usetimefestival, String discountinfofestival, + String spendtimefestival, String festivalgrade + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LodgeItem( + String contentid, String contenttypeid, + String goodstay, String benikia, + String hanok, String roomcount, + String roomtype, String checkintime, + String checkouttime, String chkcooking, + String seminar, String sports, + String sauna, String beauty, + String beverage, String karaoke, + String barbecue, String campfire, + String bicycle, String fitness, + String publicpc, String publicbath, + String subfacility, String foodplace, + String pickup, String infocenterlodging, + String parkinglodging, String reservationlodging, + String scalelodging, String accomcountlodging, + String reservationurl, String refundregulation + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record RestaurantItem( + String contentid, String contenttypeid, + String seat, String kidsfacility, + String firstmenu, String treatmenu, + String smoking, String packing, + String infocenterfood, String scalefood, + String parkingfood, String opendatefood, + String opentimefood, String restdatefood, + String discountinfofood, String chkcreditcardfood, + String reservationfood, String lcnsno + ) {} +} diff --git a/src/main/java/com/batch/dto/DetailPetTourResponse.java b/src/main/java/com/batch/dto/DetailPetTourResponse.java new file mode 100644 index 0000000..9038b1c --- /dev/null +++ b/src/main/java/com/batch/dto/DetailPetTourResponse.java @@ -0,0 +1,81 @@ +package com.batch.dto; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.io.IOException; + +// 지역기반검색 (https://apis.data.go.kr/B551011/KorPetTourService/detailPetTour) +public record DetailPetTourResponse(Response response){ + + public record Response( + Header header, + Body body + ) {} + + public record Header( + String resultCode, + String resultMsg + ) {} + + public record Body( + Items items, + int numOfRows, + int pageNo, + int totalCount + ) {} + + public record Items(List item) { + + @JsonCreator + public static Items from(JsonNode node) throws IOException { + + // ObjectMapper는 한 번만 생성하는 것이 효율적입니다. + final ObjectMapper mapper = new ObjectMapper(); + + // 1. items 필드가 비어있는 문자열("")일 경우 + if (node.isTextual() && node.asText().isEmpty()) { + return new Items(Collections.emptyList()); + } + + // 2. 정상적인 JSON 객체일 경우 + if (node.isObject()) { + // "item"이라는 이름의 하위 노드를 찾습니다. + JsonNode itemNode = node.get("item"); + + // 하위 노드가 없거나 배열이 아니면 빈 리스트로 처리합니다. + if (itemNode == null || !itemNode.isArray()) { + return new Items(Collections.emptyList()); + } + + // ✨ itemNode(배열)를 List 타입으로 직접 변환합니다. + List itemList = mapper.convertValue(itemNode, new TypeReference<>() {}); + return new Items(itemList); + } + + // 3. 그 외의 모든 경우 (null 등) + return new Items(Collections.emptyList()); + } + + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Item( + String acmpyNeedMtr, // 동반시 준비물 + String contentid, + String relaAcdntRiskMtr,// 사고 대비사항 + String acmpyTypeCd, // 동반가능 동물 + String relaPosesFclty, // 보유시설 + String relaFrnshPrdlst, // 비치품목 + String etcAcmpyInfo, // 기타 동반 정보 + String relaPurcPrdlst, // 구매가능 품목 + String acmpyPsblCpam, // 동반가능 크기 + String relaRntlPrdlst // 대여가능 품목 + ){} +} diff --git a/src/main/java/com/batch/listener/CustomJobExecutionListener.java b/src/main/java/com/batch/listener/CustomJobExecutionListener.java new file mode 100644 index 0000000..fd0ce97 --- /dev/null +++ b/src/main/java/com/batch/listener/CustomJobExecutionListener.java @@ -0,0 +1,29 @@ +package com.batch.listener; + +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CustomJobExecutionListener implements JobExecutionListener { + @Override + public void beforeJob(JobExecution jobExecution) { + log.info("[배치 Job 시작] Job 이름 : {}, Job Id : {}", + jobExecution.getJobInstance().getJobName(), jobExecution.getId()); + log.info("Job Parameter : {}", jobExecution.getJobParameters()); + } + + @Override + public void afterJob(JobExecution jobExecution) { + log.info("[배치 JOB 종료] Job 이름: {}, Job Id : {}, 상태 : {}", + jobExecution.getJobInstance().getJobName(), jobExecution.getJobId(), jobExecution.getStatus()); + + if (jobExecution.getStatus().isUnsuccessful()) { + log.error("Job {} 이 상태 {} 로 완료 되었습니다. 실패 : {}", + jobExecution.getJobInstance().getJobName(), jobExecution.getStatus(), jobExecution.getAllFailureExceptions()); + } + } +} diff --git a/src/main/java/com/batch/listener/CustomSkipListener.java b/src/main/java/com/batch/listener/CustomSkipListener.java new file mode 100644 index 0000000..26d7314 --- /dev/null +++ b/src/main/java/com/batch/listener/CustomSkipListener.java @@ -0,0 +1,28 @@ +package com.batch.listener; + +import org.springframework.batch.core.SkipListener; +import org.springframework.stereotype.Component; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class CustomSkipListener implements SkipListener { + + @Override + public void onSkipInRead(Throwable t) { + log.warn("[스킵] Reader 단계에서 스킵 사유 : {}", t.getMessage()); + } + + @Override + public void onSkipInWrite(Object item, Throwable t) { + log.warn("[스킵] Writer 단계에서 스킵 발생, Item: {}, 사유 : {}", item, t.getMessage()); + } + + @Override + public void onSkipInProcess(Content item, Throwable t) { + log.warn("[SKIP] Processor 단계에서 스킵 발생, ContentId : {}, 사유 : {}, {}", item.getContentId(), t.getMessage(), t.getStackTrace()); + } +} diff --git a/src/main/java/com/batch/listener/CustomStepExecutionListener.java b/src/main/java/com/batch/listener/CustomStepExecutionListener.java new file mode 100644 index 0000000..cb147ec --- /dev/null +++ b/src/main/java/com/batch/listener/CustomStepExecutionListener.java @@ -0,0 +1,38 @@ +package com.batch.listener; + +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; + + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CustomStepExecutionListener implements StepExecutionListener { + + @Override + public void beforeStep(StepExecution stepExecution) { + log.info("[배치 스탭 시작] 스탭 이름 : {}, Job 이름 : {}", + stepExecution.getStepName(), stepExecution.getJobExecution().getJobInstance().getJobName()); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + log.info("[배치 스탭 종료] 스탭 이름 : {}, 상태 : {}, 읽기 : {}, 쓰기 : {}, 스킵 : {}, 실패 : {}", + stepExecution.getStepName(), + stepExecution.getStatus(), + stepExecution.getReadCount(), + stepExecution.getWriteCount(), + stepExecution.getSkipCount(), + stepExecution.getFailureExceptions().size()); + if (stepExecution.getStatus().isUnsuccessful()) { + log.error("{} 스텝이 {} 상태로 완료되었습니다. 이유 : {}", + stepExecution.getStepName(), + stepExecution.getStatus(), + stepExecution.getFailureExceptions()); + } + return stepExecution.getExitStatus(); + } +} diff --git a/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java b/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java new file mode 100644 index 0000000..90102a1 --- /dev/null +++ b/src/main/java/com/batch/processor/AreaBasedListItemProcessor.java @@ -0,0 +1,83 @@ +package com.batch.processor; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import com.batch.dto.AreaBasedListResponse.Item; +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class AreaBasedListItemProcessor implements ItemProcessor { + + @Override + public Content process(Item item) throws Exception { + if (item == null) { + log.warn("Null item received in AreaBasedListItemProcessor."); + return null; + } + + if(item.contentid() == null || item.contentid().isEmpty()) { + log.warn("Content ID가 존재하지 않아 아이템 스킵 ContentId : {}", item.contentid()); + return null; + } + try { + Content content = Content.builder() + .contentId(Integer.parseInt(item.contentid())) + .contentTypeId(Integer.parseInt(item.contenttypeid())) + .categoryId(item.cat3()) + .title(item.title()) + .addr1(item.addr1()) + .addr2(item.addr2()) + .image(item.firstimage()) + .thumbImage(item.firstimage2()) + .mapx(Double.parseDouble(item.mapx())) + .mapy(Double.parseDouble(item.mapy())) + .mLevel(Integer.parseInt(item.mlevel().isEmpty() ? "0" : item.mlevel())) + .tel(item.tel()) + .zipCode(encodeZipCode(item.zipcode())) + .sigunguCode(Integer.parseInt(item.sigungucode().isEmpty() ? "0" : item.sigungucode())) + .sidoCode(Integer.parseInt(item.areacode().isEmpty() ? "0" : item.areacode())) + .copyright(item.cpyrhtDivCd()) + .createdAt(LocalDateTime.now()) + .modifiedAt(parseLocalDateTime(item.modifiedtime())) + .build(); + return content; + }catch (Exception e) { + log.error("AreaBasedList item 처리중 오류가 발생했습니다 ({}) : {}", item.contentid(), e.getMessage(), e); + return null; + } + } + + private LocalDateTime parseLocalDateTime(String dateStr) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + try { + LocalDateTime localDateTime = LocalDateTime.parse(dateStr, formatter); + return localDateTime; + } catch(DateTimeParseException e) { + log.warn("modified 데이터 파싱 실패 : {} - {} ", dateStr, e.getMessage()); + return LocalDateTime.now(); + } + } + + private int encodeZipCode(String zipCode) { + try { + if(zipCode == null || zipCode.isEmpty()) { + return 0; + } + if(zipCode.contains(",")) { + return Integer.parseInt(zipCode.split(",")[0].replaceAll("^[0-9]", "")); + } else { + return Integer.parseInt(zipCode); + } + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/main/java/com/batch/processor/DetailCommonProcessor.java b/src/main/java/com/batch/processor/DetailCommonProcessor.java new file mode 100644 index 0000000..1495363 --- /dev/null +++ b/src/main/java/com/batch/processor/DetailCommonProcessor.java @@ -0,0 +1,59 @@ +package com.batch.processor; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailCommonResponse; +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class DetailCommonProcessor implements ItemProcessor { + + private final RestClient restClient; + private final String serviceKey; + + public DetailCommonProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public Content process(Content content) throws Exception { + log.info("{} ({}), 설명 정보 수집 중", content.getTitle(), content.getContentId()); + + DetailCommonResponse response = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/detailCommon") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("_type", "json") + .queryParam("contentId", content.getContentId()) + .queryParam("overviewYN", "Y") + .build()) + .retrieve() + .body(DetailCommonResponse.class); + + DetailCommonResponse.Response bodyResponse = (response != null) ? response.response() : null; + DetailCommonResponse.Body body = (bodyResponse != null) ? bodyResponse.body() : null; + DetailCommonResponse.Items items = (body != null) ? body.items() : null; + + if (response != null && response.response().body().items() != null && !response.response().body().items().item().isEmpty()) { + String overview = response.response().body().items().item().get(0).overview(); + if (overview == null || overview.isEmpty()) log.warn("{} ({}), 설명 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + content.setOverview(overview); + } + return content; + } +} diff --git a/src/main/java/com/batch/processor/DetailImageProcessor.java b/src/main/java/com/batch/processor/DetailImageProcessor.java new file mode 100644 index 0000000..98635c5 --- /dev/null +++ b/src/main/java/com/batch/processor/DetailImageProcessor.java @@ -0,0 +1,71 @@ +package com.batch.processor; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailImageResponse; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class DetailImageProcessor implements ItemProcessor> { + + private final RestClient restClient; + private final String serviceKey; + + public DetailImageProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public List process(Content content) throws Exception { + log.info("{} ({}), 이미지 정보 수집 중", content.getTitle(), content.getContentId()); + + DetailImageResponse response = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/detailImage") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("_type", "json") + .queryParam("contentId", content.getContentId()) + .build() + ) + .retrieve() + .body(DetailImageResponse.class); + + DetailImageResponse.Response bodyResponse = (response != null) ? response.response() : null; + DetailImageResponse.Body body = (bodyResponse != null) ? bodyResponse.body() : null; + DetailImageResponse.Items items = (body != null) ? body.items() : null; + + if (items == null || items.item() == null || items.item().isEmpty()) { + log.warn("{} ({}), 이미지 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return Collections.emptyList(); // 이미지가 없으면 빈 리스트 반환 + } + + return response.response().body().items().item().stream() + .map(item -> { + return ContentImage.builder() + .content(content) + .imageUrl(item.originimgurl()) + .smallImageUrl(item.smallimageurl()) + .build(); + }).collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/batch/processor/DetailInfoProcessor.java b/src/main/java/com/batch/processor/DetailInfoProcessor.java new file mode 100644 index 0000000..1a3523b --- /dev/null +++ b/src/main/java/com/batch/processor/DetailInfoProcessor.java @@ -0,0 +1,154 @@ +package com.batch.processor; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailInfoApiResponse; +import com.batch.dto.DetailInfoProcessResult; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoom; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoomImage; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class DetailInfoProcessor implements ItemProcessor { + + private final RestClient restClient; + private final String serviceKey; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public DetailInfoProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public DetailInfoProcessResult process(Content content) throws Exception { + log.info("{} ({}, {}), 장소 반복 정보 수집 중", content.getTitle(), content.getContentId(), content.getContentTypeId()); + // DetailInfoApiResponse response = restClient.get() + ResponseEntity responseEntity = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/detailInfo") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("contentId", content.getContentId()) + .queryParam("contentTypeId", content.getContentTypeId()) + .queryParam("_type", "json") + .build() + ).retrieve() + // .body(DetailInfoApiResponse.class); + .toEntity(String.class); + + log.info("status :: {} ", responseEntity.getStatusCode()); + log.info("body :: {} ", responseEntity.getBody()); + + DetailInfoApiResponse response = objectMapper.readValue(responseEntity.getBody(), DetailInfoApiResponse.class); + + if(response == null || response.response() == null || response.response().body() == null) { + log.warn("{} ({}), 장소의 반복 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return new DetailInfoProcessResult(null, null); + } + + JsonNode itemsNode = response.response().body().items(); + if(itemsNode == null || itemsNode.isEmpty()) { + log.warn("{} ({}), ItemsNode 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return new DetailInfoProcessResult(null, null); + } + + // 숙박 + if("32".equals(String.valueOf(content.getContentTypeId()))) { + log.info("{} ({}), 숙소 반복 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + DetailInfoApiResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + if(items == null || items.item() == null || items.item().isEmpty()) { + log.warn("{} ({}), 장소의 숙소 반복 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return new DetailInfoProcessResult(null, null); + } + + List rooms = items.item().stream().map(dto -> { + RecurInformationRoom room = RecurInformationRoom.builder() + .content(content) + .roomTitle(dto.roomtitle()) + .roomSize1(Integer.valueOf(dto.roomsize1())) + .roomCount(Integer.valueOf(dto.roomcount())) + .roomBaseCount(Integer.valueOf(dto.roombasecount())) + .roomMaxCount(Integer.valueOf(dto.roommaxcount())) + .offSeasonWeekMinFee(Integer.valueOf(dto.roomoffseasonminfee1())) + .offSeasonWeekendMinFee(Integer.valueOf(dto.roomoffseasonminfee2())) + .peakSeasonWeekMinFee(Integer.valueOf(dto.roompeakseasonminfee1())) + .peakSeasonWeekendMinFee(Integer.valueOf(dto.roompeakseasonminfee2())) + .roomIntro(dto.roomintro()) + .roomBathFacility(dto.roombathfacility().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomBath(dto.roombath().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomHomeTheater(dto.roomhometheater().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomAircondition(dto.roomaircondition().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomTv(dto.roomtv().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomPc(dto.roompc().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomCable(dto.roomcable().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomInternet(dto.roominternet().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomRefrigerator(dto.roomrefrigerator().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomToiletries(dto.roomtoiletries().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomSofa(dto.roomsofa().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomCook(dto.roomcook().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomTable(dto.roomtable().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomHairdryer(dto.roomhairdryer().equals("Y") ? Boolean.TRUE : Boolean.FALSE) + .roomSize2(new BigDecimal(dto.roomsize2())) + .images(new ArrayList<>()).build(); + addRoomImageIfPresent(room, dto.roomimg1(), dto.roomimg1alt(), dto.roomimg1cpyrhtdiv()); + addRoomImageIfPresent(room, dto.roomimg2(), dto.roomimg2alt(), dto.roomimg2cpyrhtdiv()); + addRoomImageIfPresent(room, dto.roomimg3(), dto.roomimg3alt(), dto.roomimg3cpyrhtdiv()); + addRoomImageIfPresent(room, dto.roomimg4(), dto.roomimg4alt(), dto.roomimg4cpyrhtdiv()); + addRoomImageIfPresent(room, dto.roomimg5(), dto.roomimg5alt(), dto.roomimg5cpyrhtdiv()); + return room; + }).collect(Collectors.toList()); + return new DetailInfoProcessResult(null, rooms); + } else { + log.info("{} ({}), 일반 반복 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + DetailInfoApiResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + if(items == null || items.item() == null || items.item().isEmpty()) { + return new DetailInfoProcessResult(null, null); + } + List infos = items.item().stream() + .map(dto -> RecurInformation.builder() + .content(content) + .infoName(dto.infoname()) + .infoText(dto.infotext()) + .build() + ).collect(Collectors.toList()); + return new DetailInfoProcessResult(infos, null); + } + } + + private void addRoomImageIfPresent(RecurInformationRoom room, String url, String alt, String copyright) { + if(url != null && !url.trim().isEmpty()) { + RecurInformationRoomImage image = RecurInformationRoomImage.builder() + .room(room) + .imageUrl(url) + .imageAlt(alt) + .imageCopyright(copyright) + .build(); + room.getImages().add(image); + } + } +} diff --git a/src/main/java/com/batch/processor/DetailIntroProcessor.java b/src/main/java/com/batch/processor/DetailIntroProcessor.java new file mode 100644 index 0000000..78afb97 --- /dev/null +++ b/src/main/java/com/batch/processor/DetailIntroProcessor.java @@ -0,0 +1,276 @@ +package com.batch.processor; + +import java.net.URI; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailIntroProcessResult; +import com.batch.dto.DetailIntroResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class DetailIntroProcessor implements ItemProcessor { + + private final RestClient restClient; + private final String serviceKey; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public DetailIntroProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public DetailIntroProcessResult process(Content content) throws Exception { + ResponseEntity responseEntity = restClient.get() + .uri(uriBuilder -> { + URI uri = uriBuilder + .path("/detailIntro") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("contentId", content.getContentId()) + .queryParam("contentTypeId", content.getContentTypeId()) + .queryParam("_type", "json") + .build(); + log.info("소개정보 :: {}", uri); + return uri; + } + ) + .retrieve() + .toEntity(String.class); + + if(responseEntity.getBody() != null && responseEntity.getBody().contains("LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR")) { + log.warn("DetailIntroProcessor API 호출 한도 도달. 아이템 스킵"); + return null; + } + + DetailIntroResponse response = objectMapper.readValue(responseEntity.getBody(), DetailIntroResponse.class); + + if(response == null || response.response() == null || response.response().body() == null) { + log.warn("{} ({}), 장소의 소개 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return null; + } + + JsonNode itemsNode = response.response().body().items().get("item"); + if(itemsNode == null || itemsNode.isEmpty()) { + log.warn("{} ({}), ItemsNode 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return null; + } + + log.info("itemsNode :: {}", itemsNode); + + switch (content.getContentTypeId()) { + case 12 -> { + log.info("{} ({}), 관광지 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + //DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List sightsItems = objectMapper.convertValue( + itemsNode, + new TypeReference>() {} + ); + + DetailIntroResponse.Items items = + new DetailIntroResponse.Items<>(sightsItems); + + List infos = items.item().stream() + .map(dto -> SightsInformation.builder() + .content(content) + .contentTypeId(content.getContentTypeId()) + .accomCount(parseAccom(dto.accomcount())) + .chkCreditcard(dto.chkcreditcard()) + .expAgeRange(dto.expagerange()) + .expGuide(dto.expguide()) + .infoCenter(dto.infocenter()) + .openDate(parseDate(dto.opendate(), 12)) + .parking(dto.parking()) + .restDate(dto.restdate()) + .useSeason(dto.useseason()) + .useTime(dto.usetime()) + .heritage1(dto.heritage1().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .heritage2(dto.heritage2().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .heritage3(dto.heritage3().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .build() + ) + .collect(Collectors.toList()); + log.info("infos :: {}", infos); + return new DetailIntroProcessResult(infos, null, null, null); + } + + case 15 -> { + log.info("{} ({}), 축제공연행사 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + //DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List festivalItems = objectMapper.convertValue( + itemsNode, + new TypeReference>() {} + ); + + DetailIntroResponse.Items items = + new DetailIntroResponse.Items<>(festivalItems); + + List infos = items.item().stream() + .map(dto -> FestivalInformation.builder() + .content(content) + .ageLimit(dto.agelimit()) + .bookingPlace(dto.bookingplace()) + .discountInfo(dto.discountinfofestival()) + .eventStartDate(parseDate(dto.eventstartdate(), 15)) + .eventEndDate(parseDate(dto.eventenddate(), 15)) + .eventHomepage(dto.eventhomepage()) + .eventPlace(dto.eventplace()) + .placeInfo(dto.placeinfo()) + .playTime(dto.playtime()) + .program(dto.program()) + .spendTime(dto.spendtimefestival()) + .organizer(dto.sponsor1()) + .organizerTel(dto.sponsor1tel()) + .supervisor(dto.sponsor2()) + .supervisorTel(dto.sponsor2tel()) + .subEvent(dto.subevent()) + .feeInfo(dto.usetimefestival()) + .build() + ) + .collect(Collectors.toList()); + return new DetailIntroProcessResult(null, null, null, infos); + } + + case 32 -> { + log.info("{} ({}), 숙박 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + //DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List lodgeItems = objectMapper.convertValue( + itemsNode, + new TypeReference>() {} + ); + + DetailIntroResponse.Items items = + new DetailIntroResponse.Items<>(lodgeItems); + + List infos = items.item().stream() + .map(dto -> LodgeInformation.builder() + .content(content) + .capacityCount(parseAccom(dto.accomcountlodging())) + .benikia(dto.benikia().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .checkInTime(dto.checkintime()) + .checkOutTime(dto.checkouttime()) + .cooking(dto.chkcooking()) + .foodplace(dto.foodplace()) + .pickupService(dto.pickup()) + .goodstay(dto.goodstay().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .hanok(dto.hanok().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .information(dto.infocenterlodging()) + .parking(dto.parkinglodging()) + .roomCount(parseAccom(dto.roomcount())) + .reservationInfo(dto.reservationlodging()) + .reservationUrl(dto.reservationurl()) + .roomType(dto.roomtype()) + .scale(dto.scalelodging()) + .subFacility(dto.subfacility()) + .barbecue(dto.barbecue().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .beauty(dto.beauty().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .beverage(dto.beverage().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .bicycle(dto.bicycle().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .campfire(dto.campfire().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .fitness(dto.fitness().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .karaoke(dto.karaoke().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .publicBath(dto.publicbath().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .publicPcRoom(dto.publicpc().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .sauna(dto.sauna().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .seminar(dto.seminar().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .sports(dto.sports().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .refundRegulation(dto.refundregulation()) + .build() + ) + .collect(Collectors.toList()); + return new DetailIntroProcessResult(null, infos, null, null); + } + + case 39 -> { + log.info("{} ({}), 음식점 소개 정보 데이터 삽입 준비중", content.getTitle(), content.getContentId()); + //DetailIntroResponse.Items items = objectMapper.convertValue(itemsNode, new TypeReference<>() {}); + + List restaurantItems = objectMapper.convertValue( + itemsNode, + new TypeReference>() {} + ); + + DetailIntroResponse.Items items = + new DetailIntroResponse.Items<>(restaurantItems); + + List infos = items.item().stream() + .map(dto -> RestaurantInformation.builder() + .content(content) + .chkCreditcard(dto.chkcreditcardfood()) + .discountInfo(dto.discountinfofood()) + .signatureMenu(dto.firstmenu()) + .information(dto.infocenterfood()) + .kidsFacility(dto.kidsfacility().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .openDate(parseDate(dto.opendatefood(), 39)) + .openTime(dto.opentimefood()) + .parking(dto.parkingfood()) + .reservation(dto.reservationfood()) + .scale(dto.scalefood().isEmpty() ? 0 : Integer.parseInt(dto.scalefood())) + .smoking(dto.smoking().equals("1") ? Boolean.TRUE : Boolean.FALSE) + .treatMenu(dto.treatmenu()) + .restDate(dto.restdatefood()) + .seat(dto.seat()) + .build() + ) + .collect(Collectors.toList()); + return new DetailIntroProcessResult(null, null, infos, null); + } + + } + return null; + } + + private LocalDate parseDate(String dateStr, int contentType) { + if(dateStr == null || dateStr.isEmpty()) { + return null; + } + try { + switch (contentType) { + case 15 -> { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + } + default -> { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd")); + } + } + } catch (DateTimeParseException e) { + return null; + } + } + + private int parseAccom(String accom) { + String replaceAccom = accom.replaceAll("[^0-9]", ""); + return replaceAccom.isEmpty() ? 0 : Integer.parseInt(replaceAccom); + } +} diff --git a/src/main/java/com/batch/processor/DetailPetTourProcessor.java b/src/main/java/com/batch/processor/DetailPetTourProcessor.java new file mode 100644 index 0000000..a6a7e08 --- /dev/null +++ b/src/main/java/com/batch/processor/DetailPetTourProcessor.java @@ -0,0 +1,81 @@ +package com.batch.processor; + +import java.util.Collections; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.batch.dto.DetailImageResponse; +import com.batch.dto.DetailPetTourResponse; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class DetailPetTourProcessor implements ItemProcessor { + + private final RestClient restClient; + private final String serviceKey; + + public DetailPetTourProcessor( + RestClient.Builder restClientBuilder, + @Value("${tour.api.base-url}") String baseUrl, + @Value("${tour.api.service-key}") String serviceKey + ) { + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .build(); + this.serviceKey = serviceKey; + } + + @Override + public PetGuide process(Content content) throws Exception { + log.info("{} ({}), 반려동물 이용 가이드 수집 중", content.getTitle(), content.getContentId()); + + DetailPetTourResponse response = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/detailPetTour") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "ETC") + .queryParam("MobileApp", "Catsgotogedog") + .queryParam("_type", "json") + .queryParam("contentId", content.getContentId()) + .build() + ) + .retrieve() + .body(DetailPetTourResponse.class); + + DetailPetTourResponse.Response bodyResponse = (response != null) ? response.response() : null; + DetailPetTourResponse.Body body = (bodyResponse != null) ? bodyResponse.body() : null; + DetailPetTourResponse.Items items = (body != null) ? body.items() : null; + + if (response == null || response.response().body().items() == null || response.response().body().items().item() == null) { + log.info("{} ({}), 반려동물 이용 가이드 정보가 없어 스킵됩니다.", content.getTitle(), content.getContentId()); + return null; + } + + DetailPetTourResponse.Item item = response.response().body().items().item().get(0); + + if(item.acmpyPsblCpam().equals("불가능")) { + log.info("{} ({}) 반려동물 동반 불가능하여 스킵", content.getTitle(), content.getContentId()); + return null; + } + + return PetGuide.builder() + .content(content) + .petPrep(item.acmpyNeedMtr()) //동반시 준비물 + .accidentPrep(item.relaAcdntRiskMtr()) // 사고 대비사항 + .allowedPetType(item.acmpyPsblCpam()) // 동반가능 동물 + .availableFacility(item.relaPosesFclty()) // 보유시설 + .providedItem(item.relaFrnshPrdlst()) // 비치품목 + .etcInfo(item.etcAcmpyInfo()) // 기타 동반 정보 + .purchasableItem(item.relaPurcPrdlst()) // 구매가능 품목 + .withPet(item.acmpyTypeCd()) // 동반 가능 구역 + .rentItem(item.relaRntlPrdlst()) // 대여가능 품목 + .build(); + } +} diff --git a/src/main/java/com/batch/reader/AreaBasedListApiReader.java b/src/main/java/com/batch/reader/AreaBasedListApiReader.java new file mode 100644 index 0000000..488ae21 --- /dev/null +++ b/src/main/java/com/batch/reader/AreaBasedListApiReader.java @@ -0,0 +1,132 @@ +package com.batch.reader; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Iterator; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.NonTransientResourceException; +import org.springframework.batch.item.ParseException; +import org.springframework.batch.item.UnexpectedInputException; +import org.springframework.stereotype.Component; + +import com.batch.client.MigrationClient; +import com.batch.dto.AreaBasedListResponse; +import com.batch.dto.AreaBasedListResponse.Item; +import com.batch.dto.ContentTypeId; +import com.swyp.catsgotogedog.content.repository.ContentRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AreaBasedListApiReader implements ItemReader { + + private final MigrationClient migrationClient; + private final ContentRepository contentRepository; + private final Queue itemQueue = new ConcurrentLinkedQueue<>(); + + private int currentPageNo = 1; + private final int numOfRows = 100; + private int currentContentTypeIndex = 0; + private int totalPages = 1; + private boolean isInitialLoad = false; + private String modifiedTimeParam = null; + + private Iterator itemIterator; + private List contentTypeIds = List.of( + String.valueOf(ContentTypeId.관광지.getContentTypeId()), + String.valueOf(ContentTypeId.축제공연행사.getContentTypeId()), + String.valueOf(ContentTypeId.숙박.getContentTypeId()), + String.valueOf(ContentTypeId.음식점.getContentTypeId()) + ); + + @Override + public Item read() throws + Exception, + UnexpectedInputException, + ParseException, + NonTransientResourceException { + + if (!itemQueue.isEmpty()) { + return itemQueue.poll(); + } + + boolean hasMore = fetchNextPage(); + if (!hasMore || itemQueue.isEmpty()) { + return null; + } + + return itemQueue.poll(); + } + + private boolean fetchNextPage() { + while(currentContentTypeIndex < contentTypeIds.size()) { + + if(currentPageNo == 1 && currentContentTypeIndex == 0) { + isInitialLoad = contentRepository.count() == 0; + modifiedTimeParam = isInitialLoad + ? null + : LocalDate.now().minusDays(3).format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + log.info("Content {} 데이터 처리 시작 :: {}", + isInitialLoad ? "초기" : "갱신", modifiedTimeParam); + } + + var currentContentTypeId = contentTypeIds.get(currentContentTypeIndex); + log.info("처리 중인 ContentType ID: {}, 페이지: {}/{}", currentContentTypeId, currentPageNo, totalPages); + + AreaBasedListResponse response; + try { + response = migrationClient.getAreaBasedLists(currentPageNo, numOfRows, currentContentTypeId, modifiedTimeParam); + } catch(Exception e) { + log.error("API 호출 중 오류 발생 (contentTypeId : {}, pageNo : {})", currentContentTypeId, currentPageNo, e); + moveToNextContentType(); + continue; + } + + if (response == null || response.response() == null || response.response().body() == null) { + log.warn("응답 없음 - contentTypeId: {}, pageNo: {}", currentContentTypeId, currentPageNo); + moveToNextContentType(); + continue; + } + var body = response.response().body(); + var items = (body.items() == null) ? null : body.items().item(); + + if (items == null || items.isEmpty()) { + log.info("더 이상 데이터 없음 - contentTypeId: {}, pageNo: {}", currentContentTypeId, currentPageNo); + moveToNextContentType(); + continue; + } + + itemQueue.addAll(items); + + if (currentPageNo == 1) { + totalPages = (int) Math.ceil((double) body.totalCount() / numOfRows); + log.info("총 건수: {}, 총 페이지: {}", body.totalCount(), totalPages); + } + + currentPageNo++; + + if (currentPageNo > totalPages) { + log.info("ContentType 완료 - {}", currentContentTypeId); + moveToNextContentType(); + } + + return true; + } + log.info("모든 ContentType 처리 완료"); + return false; + } + + private void moveToNextContentType() { + currentContentTypeIndex++; + currentPageNo = 1; + totalPages = 1; + } +} diff --git a/src/main/java/com/batch/reader/DetailCommonReader.java b/src/main/java/com/batch/reader/DetailCommonReader.java new file mode 100644 index 0000000..f0c34d5 --- /dev/null +++ b/src/main/java/com/batch/reader/DetailCommonReader.java @@ -0,0 +1,30 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class DetailCommonReader { + + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaPagingItemReader detailCommonItemReader() { + String jpqlQuery = "SELECT c FROM Content c WHERE c.overview IS NULL OR c.overview = '' ORDER BY c.contentId ASC"; + + return new JpaPagingItemReaderBuilder() + .name("detailCommonItemReader") + .entityManagerFactory(entityManagerFactory) + .pageSize(100) + .queryString(jpqlQuery) + .build(); + } +} diff --git a/src/main/java/com/batch/reader/DetailImageReader.java b/src/main/java/com/batch/reader/DetailImageReader.java new file mode 100644 index 0000000..0888022 --- /dev/null +++ b/src/main/java/com/batch/reader/DetailImageReader.java @@ -0,0 +1,30 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentImageRepository; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class DetailImageReader { + + private final ContentImageRepository contentImageRepository; + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaPagingItemReader detailImageContentReader() { + return new JpaPagingItemReaderBuilder() + .name("contentReader") + .entityManagerFactory(entityManagerFactory) + .pageSize(100) + .queryString("SELECT c FROM Content c WHERE NOT EXISTS (SELECT 1 FROM ContentImage ci WHERE ci.content.contentId = c.contentId) ORDER BY c.contentId ASC") + .build(); + } +} diff --git a/src/main/java/com/batch/reader/DetailInfoReader.java b/src/main/java/com/batch/reader/DetailInfoReader.java new file mode 100644 index 0000000..3f254a6 --- /dev/null +++ b/src/main/java/com/batch/reader/DetailInfoReader.java @@ -0,0 +1,33 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class DetailInfoReader { + + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaPagingItemReader detailInfoItemReader() { + String jpqlQuery = "SELECT c FROM Content c " + + "WHERE NOT EXISTS (SELECT 1 FROM RecurInformation ri WHERE ri.content = c) " + + "AND NOT EXISTS (SELECT 1 FROM RecurInformationRoom rir WHERE rir.content = c) " + + "ORDER BY c.contentId ASC"; + + return new JpaPagingItemReaderBuilder() + .name("detailInfoItemReader") + .entityManagerFactory(entityManagerFactory) + .pageSize(100) + .queryString(jpqlQuery) + .build(); + } +} diff --git a/src/main/java/com/batch/reader/DetailIntroReader.java b/src/main/java/com/batch/reader/DetailIntroReader.java new file mode 100644 index 0000000..aea802d --- /dev/null +++ b/src/main/java/com/batch/reader/DetailIntroReader.java @@ -0,0 +1,80 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaCursorItemReader; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Configuration +@RequiredArgsConstructor +@Slf4j +public class DetailIntroReader { + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaCursorItemReader sightsInformationItemReader() { + String jpqlQuery = + "SELECT c " + + "FROM Content c " + + "WHERE c.contentTypeId = 12 " + + "AND NOT EXISTS (SELECT 1 FROM SightsInformation si WHERE si.content = c)"; + return new JpaCursorItemReaderBuilder() + .name("sightsInformationItemReader") + .entityManagerFactory(entityManagerFactory) + .queryString(jpqlQuery) + .build(); + } + + @Bean + public JpaCursorItemReader lodgeInformationItemReader() { + String jpqlQuery = + "SELECT c " + + "FROM Content c " + + "WHERE c.contentTypeId = 32 " + + "AND NOT EXISTS (SELECT 1 FROM LodgeInformation li WHERE li.content = c) " + + "ORDER BY c.contentId"; + log.info("JPA Query : {}", jpqlQuery); + return new JpaCursorItemReaderBuilder() + .name("lodgeInformationItemReader") + .entityManagerFactory(entityManagerFactory) + .queryString(jpqlQuery) + .build(); + } + + @Bean + public JpaCursorItemReader festivalInformationItemReader() { + String jpqlQuery = + "SELECT c " + + "FROM Content c " + + "WHERE c.contentTypeId = 15 " + + "AND NOT EXISTS (SELECT 1 FROM FestivalInformation fi WHERE fi.content = c)"; + return new JpaCursorItemReaderBuilder() + .name("festivalInformationItemReader") + .entityManagerFactory(entityManagerFactory) + .queryString(jpqlQuery) + .build(); + } + + // + @Bean + public JpaCursorItemReader restaurantInformationItemReader() { + String jpqlQuery = + "SELECT c " + + "FROM Content c " + + "WHERE c.contentTypeId = 39 " + + "AND NOT EXISTS (SELECT 1 FROM RestaurantInformation ri WHERE ri.content = c)"; + return new JpaCursorItemReaderBuilder() + .name("restaurantInformationItemReader") + .entityManagerFactory(entityManagerFactory) + .queryString(jpqlQuery) + .build(); + } +} diff --git a/src/main/java/com/batch/reader/DetailPetTourReader.java b/src/main/java/com/batch/reader/DetailPetTourReader.java new file mode 100644 index 0000000..92d66a8 --- /dev/null +++ b/src/main/java/com/batch/reader/DetailPetTourReader.java @@ -0,0 +1,35 @@ +package com.batch.reader; + +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class DetailPetTourReader { + + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaPagingItemReader detailPetTourItemReader() { + String jpqlQuery = "SELECT c" + + " FROM Content c" + + " WHERE NOT EXISTS" + + " (SELECT 1 FROM PetGuide pg" + + " WHERE pg.content = c)" + + " ORDER BY c.contentId ASC"; + + return new JpaPagingItemReaderBuilder() + .name("detailPetTourItemReader") + .entityManagerFactory(entityManagerFactory) + .pageSize(100) + .queryString(jpqlQuery) + .build(); + } +} diff --git a/src/main/java/com/batch/tasklet/CategoryFetchTasklet.java b/src/main/java/com/batch/tasklet/CategoryFetchTasklet.java new file mode 100644 index 0000000..2f605bb --- /dev/null +++ b/src/main/java/com/batch/tasklet/CategoryFetchTasklet.java @@ -0,0 +1,82 @@ +package com.batch.tasklet; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +import com.batch.client.MigrationClient; +import com.batch.dto.CategoryCodeResponse; +import com.swyp.catsgotogedog.category.domain.entity.CategoryCode; +import com.swyp.catsgotogedog.category.repository.CategoryRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CategoryFetchTasklet implements Tasklet { + + private final MigrationClient migrationClient; + private final CategoryRepository categoryRepository; + + private static final List CATEGORY_LIST = Arrays.asList( + "12", // 관광지 + "14", // 문화시설 + "15", // 축제공연행사 + "25", // 여행코스 + "28", // 레포츠 + "32", // 숙박 + "38", // 쇼핑 + "39" // 음식점 + ); + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if(categoryRepository.count() > 0) { + log.info("카테고리 데이터가 존재하여 스킵"); + return RepeatStatus.FINISHED; + }; + + log.info("Category Fetch Tasklet Start"); + List categoryList = new ArrayList<>(); + + for(String category : CATEGORY_LIST) { + log.info("Fetching Category : {}", category); + + // cat1 대분류 조회 + List cat1Items = migrationClient.getCategoryCode(category, null, null); + for(CategoryCodeResponse.Item cat1Item : cat1Items) { + log.debug("cat1Item : {} ({})", cat1Item, cat1Item.getCode()); + + List cat2Items = migrationClient.getCategoryCode(category, cat1Item.getCode(), null); + + for(CategoryCodeResponse.Item cat2Item : cat2Items) { + log.debug("cat2Item : {} ({})", cat2Item, cat2Item.getCode()); + + List cat3Items = migrationClient.getCategoryCode(category, cat1Item.getCode(), cat2Item.getCode()); + + for(CategoryCodeResponse.Item cat3Item : cat3Items) { + + log.debug("cat3Item : {} ({})", cat3Item, cat3Item.getCode()); + + categoryList.add(CategoryCode.builder() + .categoryId(cat3Item.getCode()) + .categoryName(cat3Item.getName()) + .contentTypeId(Integer.parseInt(category)) + .build()); + } + } + } + } + categoryRepository.saveAll(categoryList); + log.info("Category Fetch Tasklet End"); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/batch/writer/ContentImageWriter.java b/src/main/java/com/batch/writer/ContentImageWriter.java new file mode 100644 index 0000000..c00e6e8 --- /dev/null +++ b/src/main/java/com/batch/writer/ContentImageWriter.java @@ -0,0 +1,33 @@ +package com.batch.writer; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; +import com.swyp.catsgotogedog.content.repository.ContentImageRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ContentImageWriter implements ItemWriter> { + + private final ContentImageRepository contentImageRepository; + + @Override + @Transactional + public void write(Chunk> chunk) throws Exception { + List flatList = chunk.getItems().stream() + .flatMap(List::stream) + .toList(); + log.info("{}개 이미지를 DB 삽입중", flatList.size()); + contentImageRepository.saveAll(flatList); + } +} diff --git a/src/main/java/com/batch/writer/DetailCommonWriter.java b/src/main/java/com/batch/writer/DetailCommonWriter.java new file mode 100644 index 0000000..90a1153 --- /dev/null +++ b/src/main/java/com/batch/writer/DetailCommonWriter.java @@ -0,0 +1,20 @@ +package com.batch.writer; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DetailCommonWriter implements ItemWriter { + + + @Override + public void write(Chunk chunk) throws Exception { + } +} diff --git a/src/main/java/com/batch/writer/DetailInfoWriter.java b/src/main/java/com/batch/writer/DetailInfoWriter.java new file mode 100644 index 0000000..a63d69a --- /dev/null +++ b/src/main/java/com/batch/writer/DetailInfoWriter.java @@ -0,0 +1,36 @@ +package com.batch.writer; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import com.batch.dto.DetailInfoProcessResult; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoom; +import com.swyp.catsgotogedog.content.repository.RecurInformationRepository; +import com.swyp.catsgotogedog.content.repository.RecurInformationRoomRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DetailInfoWriter implements ItemWriter { + + private final RecurInformationRepository recurInformationRepository; + private final RecurInformationRoomRepository recurInformationRoomRepository; + + @Override + public void write(Chunk chunk) throws Exception { + List generalToSave = new ArrayList<>(); + List roomsToSave = new ArrayList<>(); + for(DetailInfoProcessResult result : chunk.getItems()) { + if(result.getGeneralInfoList() != null) generalToSave.addAll(result.getGeneralInfoList()); + if(result.getRoomInfoList() != null) roomsToSave.addAll(result.getRoomInfoList()); + } + if(!generalToSave.isEmpty()) recurInformationRepository.saveAll(generalToSave); + if(!roomsToSave.isEmpty()) recurInformationRoomRepository.saveAll(roomsToSave); + } +} diff --git a/src/main/java/com/batch/writer/DetailIntroWriter.java b/src/main/java/com/batch/writer/DetailIntroWriter.java new file mode 100644 index 0000000..0d4a684 --- /dev/null +++ b/src/main/java/com/batch/writer/DetailIntroWriter.java @@ -0,0 +1,51 @@ +package com.batch.writer; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import com.batch.dto.DetailIntroProcessResult; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; +import com.swyp.catsgotogedog.content.repository.FestivalInformationRepository; +import com.swyp.catsgotogedog.content.repository.LodgeInformationRepository; +import com.swyp.catsgotogedog.content.repository.RestaurantInformationRepository; +import com.swyp.catsgotogedog.content.repository.SightsInformationRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DetailIntroWriter implements ItemWriter { + + private final SightsInformationRepository sightsInformationRepository; + private final LodgeInformationRepository lodgeInformationRepository; + private final RestaurantInformationRepository restaurantInformationRepository; + private final FestivalInformationRepository festivalInformationRepository; + + @Override + public void write(Chunk chunk) throws Exception { + List sightsToSave = new ArrayList<>(); + List lodgeToSave = new ArrayList<>(); + List restaurantToSave = new ArrayList<>(); + List festivalToSave = new ArrayList<>(); + + for(DetailIntroProcessResult result : chunk.getItems()) { + if(result.getSightsInfoList() != null) sightsToSave.addAll(result.getSightsInfoList()); + if(result.getLodgeInfoList() != null) lodgeToSave.addAll(result.getLodgeInfoList()); + if(result.getRestaurantInfoList() != null) restaurantToSave.addAll(result.getRestaurantInfoList()); + if(result.getFestivalInformationList() != null) festivalToSave.addAll(result.getFestivalInformationList()); + } + + if(!sightsToSave.isEmpty()) sightsInformationRepository.saveAll(sightsToSave); + if(!lodgeToSave.isEmpty()) lodgeInformationRepository.saveAll(lodgeToSave); + if(!restaurantToSave.isEmpty()) restaurantInformationRepository.saveAll(restaurantToSave); + if(!festivalToSave.isEmpty()) festivalInformationRepository.saveAll(festivalToSave); + + } +} diff --git a/src/main/java/com/batch/writer/DetailPetTourWriter.java b/src/main/java/com/batch/writer/DetailPetTourWriter.java new file mode 100644 index 0000000..ab824ca --- /dev/null +++ b/src/main/java/com/batch/writer/DetailPetTourWriter.java @@ -0,0 +1,22 @@ +package com.batch.writer; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; +import com.swyp.catsgotogedog.pet.repository.PetGuideRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DetailPetTourWriter implements ItemWriter { + + private final PetGuideRepository petGuideRepository; + + @Override + public void write(Chunk chunk) throws Exception { + petGuideRepository.saveAll(chunk.getItems()); + } +} diff --git a/src/main/java/com/batch/writer/ItemWriterConfig.java b/src/main/java/com/batch/writer/ItemWriterConfig.java new file mode 100644 index 0000000..628835c --- /dev/null +++ b/src/main/java/com/batch/writer/ItemWriterConfig.java @@ -0,0 +1,23 @@ +package com.batch.writer; + +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.EntityManagerFactory; +import lombok.extern.slf4j.Slf4j; + +@Configuration +@Slf4j +public class ItemWriterConfig { + + @Bean + public JpaItemWriter step1ContentWriter(EntityManagerFactory entityManagerFactory) { + JpaItemWriter writer = new JpaItemWriter<>(); + writer.setEntityManagerFactory(entityManagerFactory); + writer.setUsePersist(false); + return writer; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java index 86cdb63..f19db57 100644 --- a/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java +++ b/src/main/java/com/swyp/catsgotogedog/CatsgotogedogApplication.java @@ -1,13 +1,67 @@ package com.swyp.catsgotogedog; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; -@SpringBootApplication -public class CatsgotogedogApplication { +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; - public static void main(String[] args) { +@EnableAsync +@EnableJpaAuditing +@SpringBootApplication(scanBasePackages = { "com.swyp", "com.batch" }) +@EnableScheduling +@RequiredArgsConstructor +@Slf4j +public class CatsgotogedogApplication implements CommandLineRunner { + + private final JobLauncher jobLauncher; + private final ApplicationContext applicationContext; + + public static void main(String[] args) { SpringApplication.run(CatsgotogedogApplication.class, args); } + @Scheduled(cron = "0 20 2 * * ?") + public void runBatch() throws Exception { + log.info("############# 02시 데이터 마이그레이션 배치 진행 ##############"); + Job categoryCodeBatchJob = (Job) applicationContext.getBean("categoryCodeBatchJob"); + Job contentBatchJob = (Job) applicationContext.getBean("contentBatchJob"); + + JobParameters jobParameters = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .toJobParameters(); + jobLauncher.run(categoryCodeBatchJob, jobParameters); + log.info(">> 02:00 AM CategoryCode 배치 스케쥴러 작동"); + + jobLauncher.run(contentBatchJob, jobParameters); + log.info(">> 02:00 AM Content Fetch 배치 스케쥴러 작동"); + + } + + @Override + public void run(String... args) throws Exception { + // log.info("############# 02시 데이터 마이그레이션 배치 진행 ##############"); + // Job categoryCodeBatchJob = (Job) applicationContext.getBean("categoryCodeBatchJob"); + // Job contentBatchJob = (Job) applicationContext.getBean("contentBatchJob"); + // + // JobParameters jobParameters = new JobParametersBuilder() + // .addLong("time", System.currentTimeMillis()) + // .toJobParameters(); + // jobLauncher.run(categoryCodeBatchJob, jobParameters); + // log.info(">> 02:00 AM CategoryCode 배치 스케쥴러 작동"); + // + // jobLauncher.run(contentBatchJob, jobParameters); + // log.info(">> 01:00 AM Content Fetch 배치 스케쥴러 작동"); + } } diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java new file mode 100644 index 0000000..5c07134 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserController.java @@ -0,0 +1,104 @@ +package com.swyp.catsgotogedog.User.controller; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.domain.request.UserUpdateRequest; +import com.swyp.catsgotogedog.User.domain.response.AccessTokenResponse; +import com.swyp.catsgotogedog.User.domain.response.UserProfileResponse; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.User.service.RefreshTokenService; +import com.swyp.catsgotogedog.User.service.UserService; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/user") +@Slf4j +public class UserController implements UserControllerSwagger { + + private final JwtTokenUtil jwt; + private final RefreshTokenService rtService; + private final UserService userService; + private final UserRepository userRepo; + + @PostMapping("/reissue") + public ResponseEntity> reIssue( + @CookieValue(value = "X-Refresh-Token", required = false) String refresh) { + return ResponseEntity.ok(CatsgotogedogApiResponse.success("재발급 성공", + new AccessTokenResponse(userService.reIssue(refresh)))); + } + + @PostMapping("/logout") + public ResponseEntity> logout( + @CookieValue("X-Refresh-Token") String refresh) { + + userService.logout(refresh); + ResponseCookie cookie = ResponseCookie.from(("X-Refresh-Token"), refresh) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("None") + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(CatsgotogedogApiResponse.success("로그아웃 성공", null)); + } + + @GetMapping("/profile") + public ResponseEntity> profile( + @AuthenticationPrincipal String userId) { + User user = userService.profile(userId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("사용자 프로필 조회 성공", UserProfileResponse.from(user))); + } + + @PatchMapping("/profile") + public ResponseEntity> updateProfile( + @AuthenticationPrincipal String userId, + @Valid @ModelAttribute UserUpdateRequest request) { + userService.update(userId, request); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("프로필 수정 성공", null)); + } + + @DeleteMapping("/profile/image") + public ResponseEntity> deleteProfileImage( + @AuthenticationPrincipal String userId) { + userService.deleteProfileImage(userId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("프로필 이미지 삭제 성공", null)); + } + + //todo :: 소셜 연결 해제 구현 필요 (현재 DB를 통한 삭제만 구현) + @DeleteMapping("/withdraw") + public ResponseEntity> withdraw( + @AuthenticationPrincipal String userId, + @CookieValue("X-Refresh-Token") String refresh) { + + userService.withdraw(userId, refresh); + + ResponseCookie cookie = ResponseCookie.from(("X-Refresh-Token"), refresh) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("None") + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(CatsgotogedogApiResponse.success("회원 탈퇴 성공", null)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java new file mode 100644 index 0000000..ba7a94d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/controller/UserControllerSwagger.java @@ -0,0 +1,136 @@ +package com.swyp.catsgotogedog.User.controller; + +import com.swyp.catsgotogedog.User.domain.request.UserUpdateRequest; +import com.swyp.catsgotogedog.User.domain.response.UserProfileResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +import com.swyp.catsgotogedog.User.domain.response.AccessTokenResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +@Tag(name = "User", description = "사용자 관련 API") +public interface UserControllerSwagger { + + @Operation( + summary = "액세스 토큰 재발급", + description = "리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다.\n" + + "재발급된 토큰은 body를 통해 반환됩니다." + + " Cookie를 통해 Refresh-Token값을 읽어 재발급을 진행합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 재발급 성공" + , content = @Content(schema = @Schema(implementation = AccessTokenResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity reIssue( + @Parameter(description = "리프레시 토큰", hidden = true) + String refresh + ); + + @Operation( + summary = "로그아웃", + description = "사용자 로그아웃을 처리하고 리프레시 토큰을 제거합니다. Cookie를 통해 Refresh-Token값을 읽어 로그아웃 처리를 진행합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> logout( + @Parameter(description = "리프레시 토큰", hidden = true) + String refresh + ); + + @Operation( + summary = "사용자 프로필 조회", + description = "인증된 사용자의 프로필 정보를 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "프로필 조회 성공", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> profile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId + ); + + @Operation( + summary = "사용자 정보 수정", + description = "인증된 사용자의 정보를 수정합니다. 사용자 ID는 인증된 사용자로부터 가져옵니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사용자 정보 수정 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> updateProfile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "수정할 사용자 정보", required = true) + UserUpdateRequest request + ); + + @Operation( + summary = "사용자 프로필 이미지 삭제", + description = "인증된 사용자의 프로필 이미지를 삭제합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "프로필 이미지 삭제 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> deleteProfileImage( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId + ); + + @Operation( + summary = "회원 탈퇴", + description = """ + X-Refresh-Token 쿠키를 제거하고 기존 사용자 정보는 삭제 처리 데이터로 변경됩니다. + 리뷰는 삭제된 유저 정보로 조회됩니다. + """ + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자 또는 유효하지 않은 토큰", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> withdraw( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "리프레시 토큰", hidden = true) + String refresh + ); +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/RefreshToken.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/RefreshToken.java new file mode 100644 index 0000000..fdd18fe --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/RefreshToken.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.User.domain.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefreshToken { + + @Id + private int userId; + + private String refreshToken; + + private LocalDateTime expiresAt; + + private Boolean isRevoked; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java new file mode 100644 index 0000000..4c68a72 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/User.java @@ -0,0 +1,37 @@ +package com.swyp.catsgotogedog.User.domain.entity; + + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import com.swyp.catsgotogedog.global.BaseTimeEntity; +import com.swyp.catsgotogedog.pet.domain.entity.Pet; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="user_id") + private int userId; + + private String displayName; + private String email; + private String provider; // google / kakao / naver + private String providerId; + private String imageFilename; + private String imageUrl; + private Boolean isActive; + private LocalDateTime nameUpdateAt; // displayName 변경 시 업데이트 + @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + private List pets; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/entity/UserRole.java b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/UserRole.java new file mode 100644 index 0000000..22e460e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/entity/UserRole.java @@ -0,0 +1,5 @@ +package com.swyp.catsgotogedog.User.domain.entity; + +public enum UserRole { + USER, ADMIN; +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/User/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserUpdateRequest.java b/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserUpdateRequest.java new file mode 100644 index 0000000..d300747 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/request/UserUpdateRequest.java @@ -0,0 +1,20 @@ +package com.swyp.catsgotogedog.User.domain.request; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Getter +@Setter +public class UserUpdateRequest { + + @Size(min = 2, max = 12, message = "닉네임은 2자 이상 12자 이하로 설정해야 합니다.") + @Pattern(regexp = "^[a-zA-Z0-9가-힣]*$", message = "닉네임에 특수문자나 공백을 사용할 수 없습니다.") + private String displayName; + private List image; +} + diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/response/AccessTokenResponse.java b/src/main/java/com/swyp/catsgotogedog/User/domain/response/AccessTokenResponse.java new file mode 100644 index 0000000..a7cabd5 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/response/AccessTokenResponse.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.User.domain.response; + +public record AccessTokenResponse(String accessToken) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java b/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java new file mode 100644 index 0000000..b5a2bc7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/domain/response/UserProfileResponse.java @@ -0,0 +1,42 @@ +package com.swyp.catsgotogedog.User.domain.response; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.pet.domain.response.PetProfileResponse; +import lombok.Builder; +import lombok.Getter; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +public class UserProfileResponse { + + private String displayName; + private String email; + private String provider; + private String imageFilename; + private String imageUrl; + private List pets; + + public static UserProfileResponse from(User user) { + return UserProfileResponse.builder() + .displayName(user.getDisplayName()) + .email(user.getEmail()) + .provider(user.getProvider()) + .imageFilename(user.getImageFilename()) + .imageUrl(user.getImageUrl()) + .pets(convertPets(user)) + .build(); + } + + private static List convertPets(User user) { + if (user.getPets() == null) { + return Collections.emptyList(); + } + return user.getPets().stream() + .map(PetProfileResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/repository/RefreshTokenRepository.java b/src/main/java/com/swyp/catsgotogedog/User/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..f419e69 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/repository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.User.repository; + +import com.swyp.catsgotogedog.User.domain.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByRefreshToken(String token); + void deleteByUserId(int userId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java new file mode 100644 index 0000000..308d61e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.swyp.catsgotogedog.User.repository; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByProviderAndProviderId(String provider, String providerId); + Optional findByProviderId(String providerId); + Optional findByDisplayName(String displayName); + boolean existsByDisplayName(String displayName); +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java b/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java new file mode 100644 index 0000000..7b41d43 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/service/RefreshTokenService.java @@ -0,0 +1,42 @@ +package com.swyp.catsgotogedog.User.service; + +import com.swyp.catsgotogedog.User.domain.entity.RefreshToken; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.joda.time.DateTime; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class RefreshTokenService { + private final RefreshTokenRepository repo; + + public RefreshToken save(User user, String token, LocalDateTime expiryMs) { + repo.deleteByUserId(user.getUserId()); + RefreshToken rt = RefreshToken.builder() + .userId(user.getUserId()) + .refreshToken(token) + .expiresAt(expiryMs) + .isRevoked(Boolean.FALSE) + .build(); + return repo.save(rt); + } + + public boolean validate(String token) { + return repo.findByRefreshToken(token) + .filter(rt -> rt.getExpiresAt().isAfter(LocalDateTime.now())) + .isPresent(); + } + + public void delete(String token) { + repo.findByRefreshToken(token).ifPresent(repo::delete); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java new file mode 100644 index 0000000..979b898 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/User/service/UserService.java @@ -0,0 +1,156 @@ +package com.swyp.catsgotogedog.User.service; + +import com.swyp.catsgotogedog.User.domain.request.UserUpdateRequest; +import com.swyp.catsgotogedog.common.util.image.storage.ImageStorageService; +import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; +import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.ToxicityCheckResult; +import com.swyp.catsgotogedog.common.util.perspectiveApi.service.ToxicityCheckService; +import com.swyp.catsgotogedog.global.exception.*; +import org.springframework.stereotype.Service; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UserService { + + private final UserRepository userRepository; + private final RefreshTokenService rtService; + private final JwtTokenUtil jwt; + private final ImageStorageService imageStorageService; + private final ToxicityCheckService toxicityCheckService; + + public String reIssue(String refreshToken) { + + if(!rtService.validate(refreshToken)) { + throw new InvalidTokenException(ErrorCode.INVALID_TOKEN); + } + + int userId = Integer.parseInt(jwt.getSubject(refreshToken)); + String email = jwt.getEmail(refreshToken); + String displayName = jwt.getDisplayName(refreshToken); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new UnAuthorizedAccessException(ErrorCode.UNAUTHORIZED_ACCESS)); + + return jwt.createAccessToken(String.valueOf(userId), email, displayName); + } + + public void logout(String refreshToken) { + if (!rtService.validate(refreshToken)) { + throw new InvalidTokenException(ErrorCode.INVALID_TOKEN); + } + rtService.delete(refreshToken); + } + + public User profile(String userId) { + return findUserById(userId); + } + + public void update(String userId, UserUpdateRequest request) { + User user = findUserById(userId); + + if (request.getDisplayName() != null) { + String newDisplayName = request.getDisplayName(); + + // 같은 닉네임으로 변경 시도하는 경우 체크 + if (newDisplayName.equals(user.getDisplayName())) { + throw new CatsgotogedogException(ErrorCode.SAME_DISPLAY_NAME); + } else { + // 24시간 이내 닉네임 변경 제한 체크 + if (user.getNameUpdateAt() != null) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime lastUpdate = user.getNameUpdateAt(); + if (lastUpdate.plusHours(24).isAfter(now)) { + throw new CatsgotogedogException(ErrorCode.DISPLAY_NAME_UPDATE_TOO_SOON); + } + } + + if (userRepository.existsByDisplayName(newDisplayName)) { + throw new CatsgotogedogException(ErrorCode.DUPLICATE_DISPLAY_NAME); + } + // 닉네임 변경 + ToxicityCheckResult checkResult = toxicityCheckService.checkNickname(newDisplayName); + if (!checkResult.passed()) { + log.debug("닉네임 '{}'은(는) 독성 점수 {}로 부적절합니다. 기준치: {}", + newDisplayName, checkResult.toxicityScore(), checkResult.threshold()); + throw new CatsgotogedogException(ErrorCode.TOO_TOXIC_DISPLAY_NAME); + } + user.setDisplayName(newDisplayName); + user.setNameUpdateAt(LocalDateTime.now()); + } + } + + if (request.getImage() != null && !request.getImage().isEmpty()) { + if (StringUtils.hasText(user.getImageFilename())) { + imageStorageService.delete(user.getImageFilename()); + } + List imageInfos = imageStorageService.upload( + request.getImage(), "profile/", ImageUploadType.PROFILE); + ImageInfo imageInfo = imageInfos.get(0); + user.setImageFilename(imageInfo.key()); + user.setImageUrl(imageInfo.url()); + } + userRepository.save(user); + } + + public void deleteProfileImage(String userId) { + User user = findUserById(userId); + + if (StringUtils.hasText(user.getImageFilename())) { + imageStorageService.delete(user.getImageFilename()); + user.setImageFilename(null); + } + user.setImageUrl("https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/default_user_image.png"); + userRepository.save(user); + } + + @Transactional + public void withdraw(String userId, String refreshToken) { + // 리프레시 토큰 검증 + if (!rtService.validate(refreshToken)) { + throw new InvalidTokenException(ErrorCode.INVALID_TOKEN); + } + + User user = findUserById(userId); + + // 프로필 이미지 삭제 + if (StringUtils.hasText(user.getImageFilename())) { + imageStorageService.delete(user.getImageFilename()); + } + + // 리프레시 토큰 삭제 + rtService.delete(refreshToken); + + SecureRandom random = new SecureRandom(); + + // 비활성화 방식 + user.setEmail("none"); + user.setDisplayName("탈퇴회원_" + 10000 + random.nextInt(90000)); + user.setProvider("none"); + user.setProviderId("none"); + user.setImageFilename(null); + user.setImageUrl("https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/default_user_image.png"); + user.setIsActive(false); + } + + private User findUserById(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerController.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerController.java new file mode 100644 index 0000000..62d33a4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerController.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.aiplanner.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.swyp.catsgotogedog.aiplanner.request.PlannerRequest; +import com.swyp.catsgotogedog.aiplanner.service.AiPlannerService; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/planner") +@Slf4j +public class AiplannerController implements AiplannerControllerSwagger { + + private final AiPlannerService aiPlannerService; + + @PostMapping("/initEmbeddingData") + public void initEmbeddingData() { + aiPlannerService.initEmbedContentData(); + } + + @PostMapping("/recommend") + public ResponseEntity> createAiPlanner( + @AuthenticationPrincipal String userId, + @RequestBody PlannerRequest request + ) { + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("플래너 생성 성공", aiPlannerService.createPlan(userId, request)) + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerControllerSwagger.java new file mode 100644 index 0000000..572777c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/controller/AiplannerControllerSwagger.java @@ -0,0 +1,45 @@ +package com.swyp.catsgotogedog.aiplanner.controller; + +import org.springframework.http.ResponseEntity; + +import com.swyp.catsgotogedog.aiplanner.request.PlannerRequest; +import com.swyp.catsgotogedog.aiplanner.response.AiplannerResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.pet.domain.response.PetProfileResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Ai플래너", description = "AI 플래너 관련 API") +public interface AiplannerControllerSwagger { + + @Operation( + summary = "AI 플래너 생성", + description = "AI를 통한 플랜 생성" + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "플랜 생성 성공", + content = @Content(schema = @Schema(implementation = AiplannerResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> createAiPlanner( + @Parameter(hidden = true) + String userId, + @RequestBody(description = """ + duration은 DAY_TRIP, ONE_NIGHT, TWO_NIGHT 중 하나의 데이터를 입력해야합니다. + mood는 사용자가 입력한 기분 데이터입니다. + """, required = true) + PlannerRequest request + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/ContentEmbedding.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/ContentEmbedding.java new file mode 100644 index 0000000..64d98f4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/ContentEmbedding.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.aiplanner.domain; + +import java.util.List; + +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; +import org.springframework.data.elasticsearch.annotations.Mapping; +import org.springframework.data.elasticsearch.annotations.Setting; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Document(indexName = "content-embedding") +@Setting(settingPath = "elasticsearch/content-embed-setting.json") +@Mapping(mappingPath = "elasticsearch/content-embed-mapping.json") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class ContentEmbedding { + + @Id + private Integer contentId; + private int contentTypeId; + private String categoryId; + private int sidoCode; + private int sigunguCode; + private double mapx; + private double mapy; + private String title; + + @Field(type = FieldType.Dense_Vector, dims = 1024) + private List embedding; +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/TravelDuration.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/TravelDuration.java new file mode 100644 index 0000000..be0dfbf --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/domain/TravelDuration.java @@ -0,0 +1,15 @@ +package com.swyp.catsgotogedog.aiplanner.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TravelDuration { + DAY_TRIP(1, "당일치기"), + ONE_NIGHT(2, "1박2일"), + TWO_NIGHT(3, "2박3일"); + + private final int days; + private final String description; +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/repository/ContentEmbeddingRepository.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/repository/ContentEmbeddingRepository.java new file mode 100644 index 0000000..1426848 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/repository/ContentEmbeddingRepository.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.aiplanner.repository; + +import java.util.List; + +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +import com.swyp.catsgotogedog.aiplanner.domain.ContentEmbedding; + +public interface ContentEmbeddingRepository extends ElasticsearchRepository { + List findByContentIdIn(List contentIds); +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/request/PlannerRequest.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/request/PlannerRequest.java new file mode 100644 index 0000000..189a959 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/request/PlannerRequest.java @@ -0,0 +1,38 @@ +package com.swyp.catsgotogedog.aiplanner.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.swyp.catsgotogedog.aiplanner.domain.TravelDuration; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Valid +public class PlannerRequest { + + @JsonProperty + @NotNull(message = "여행 기간은 필수입니다.") + private TravelDuration duration; + + @JsonProperty + @NotNull(message = "대분류 여행 지역은 필수입니다.") + @Min(value = 1, message = "올바른 시도 코드를 입력해주세요.") + private Integer sidoCode; + + @JsonProperty + @NotBlank(message = "현재 기분은 필수입니다.") + @Size(max = 50, message = "기분 설명은 50자 이하로 입력해주세요") + private String mood; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/response/AiplannerResponse.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/response/AiplannerResponse.java new file mode 100644 index 0000000..7df0ecb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/response/AiplannerResponse.java @@ -0,0 +1,44 @@ +package com.swyp.catsgotogedog.aiplanner.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Setter +@Builder +public class AiplannerResponse { + private List dayPlans; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class DayPlan { + private Integer day; + private List dayContents; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ContentInfo { + private Integer contentId; + private String title; + private String categoryId; + private String addr1; + private String addr2; + private String image; + private String thumbImage; + private double mapx; + private double mapy; + private String rest; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/aiplanner/service/AiPlannerService.java b/src/main/java/com/swyp/catsgotogedog/aiplanner/service/AiPlannerService.java new file mode 100644 index 0000000..2e5806c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/aiplanner/service/AiPlannerService.java @@ -0,0 +1,414 @@ +package com.swyp.catsgotogedog.aiplanner.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.aiplanner.domain.ContentEmbedding; +import com.swyp.catsgotogedog.aiplanner.repository.ContentEmbeddingRepository; +import com.swyp.catsgotogedog.aiplanner.request.PlannerRequest; +import com.swyp.catsgotogedog.aiplanner.response.AiplannerResponse; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.RestaurantInformationRepository; +import com.swyp.catsgotogedog.content.repository.SightsInformationRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import co.elastic.clients.json.JsonData; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class AiPlannerService { + + @Value("${clova.api.embed.url}") + private String clovaUrl; + + @Value("${clova.api.key}") + private String apiKey; + + private RestClient restClient; + private final ContentEmbeddingRepository contentEmbeddingRepository; + private final ContentRepository contentRepository; + private final ElasticsearchClient esClient; + private final ObjectMapper objectMapper; + private final UserRepository userRepository; + private final SightsInformationRepository sightsInformationRepository; + private final RestaurantInformationRepository restaurantInformationRepository; + + public AiPlannerService(ContentEmbeddingRepository contentEmbeddingRepository, ContentRepository contentRepository, ElasticsearchClient esClient, ObjectMapper objectMapper, UserRepository userRepository, SightsInformationRepository sightsInformationRepository + , RestaurantInformationRepository restaurantInformationRepository) { + this.contentEmbeddingRepository = contentEmbeddingRepository; + this.contentRepository = contentRepository; + this.esClient = esClient; + this.objectMapper = objectMapper; + this.userRepository = userRepository; + this.sightsInformationRepository = sightsInformationRepository; + this.restaurantInformationRepository = restaurantInformationRepository; + } + + @PostConstruct + private void initRestClient() { + this.restClient = RestClient.builder() + .baseUrl(clovaUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("X-NCP-CLOVASTUDIO-REQUEST-ID", "f729bb278df149ce8b7e25e3ec8e1a25") + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Transactional + public void initEmbedContentData() { + int processedCount = 0; + int failedCount = 0; + + // Content 테이블 id 조회 + List allContents = contentRepository.findAll(); + + // ContentId 추출 + List allContentIds = allContents.stream() + .map(Content::getContentId) + .toList(); + + // 현재 삽입되어있는 엘라스틱서치 Content 데이터 목록 + List existEmbeddings = contentEmbeddingRepository.findByContentIdIn(allContentIds); + + // 엘라스틱서치에 존재하는 Id 추출 + Set existIds = existEmbeddings.stream() + .map(ContentEmbedding::getContentId) + .collect(Collectors.toSet()); + + // 존재하지 않는 Content 데이터 추출 + List newContents = allContents.stream() + .filter(content -> !existIds.contains(content.getContentId())) + .filter(this::isValidEmbeddingData) + .toList(); + + List contentEmbeddings = new ArrayList<>(); + + for(Content content : newContents) { + try { + if(processedCount > 0) { + Thread.sleep(1000); + } + + // 임베딩 텍스트 생성 + String textToEmbed = createEmbeddingText(content); + // Clova 호출 + List embedding = callClovaEmbedApi(textToEmbed); + + if(!embedding.isEmpty()) { + ContentEmbedding contentEmbedding = ContentEmbedding.builder() + .contentId(content.getContentId()) + .title(content.getTitle()) + .contentTypeId(content.getContentTypeId()) + .categoryId(content.getCategoryId()) + .sidoCode(content.getSidoCode()) + .sigunguCode(content.getSigunguCode()) + .mapx(content.getMapx()) + .mapy(content.getMapy()) + .embedding(embedding) + .build(); + + contentEmbeddings.add(contentEmbedding); + processedCount++; + + if(contentEmbeddings.size() >= 10) { + saveToElasticSearch(contentEmbeddings); + contentEmbeddings.clear(); + } + + if(processedCount % 10 == 0) { + log.info("임베딩 진행 상황 : {}/{}", processedCount, newContents.size()); + } + } else { + failedCount++; + log.warn("ContentId {} - 임베딩 생성 실패", content.getContentId()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("임베딩 처리 중 인터럽트 발생", e); + break; + } catch(Exception e) { + failedCount++; + log.error("ContentId {} 임베딩 처리 중 오류발생 : {}", content.getContentId(), e.getMessage()); + } + } + + + + + log.info("임베딩 데이터 삽입 완료 - 성공 : {}, 실패 : {}", processedCount, failedCount); + } + + public AiplannerResponse createPlan(String userId, PlannerRequest request) { + validateUser(userId); + + List moodEmbed = getMoodEmbed(request.getMood()); + + if(moodEmbed.isEmpty()) { + throw new CatsgotogedogException(ErrorCode.CLOVA_HASHTAG_SERVER_ERROR); + } + + List dayPlans = new ArrayList<>(); + + int days = request.getDuration().getDays(); + Set excludedIds = new HashSet<>(); + + for(int day = 1; day <= days; day++) { + AiplannerResponse.DayPlan dayPlan = createDayPlan(day, request.getSidoCode(), days, moodEmbed, excludedIds); + dayPlans.add(dayPlan); + } + + return new AiplannerResponse(dayPlans); + } + + private AiplannerResponse.DayPlan createDayPlan( + int day, int sido, int totalDays, List moodEmbed, Set excludedIds + ) { + List dayContents = new ArrayList<>(); + + dayContents.add(findBestPlace(sido, moodEmbed, 12, excludedIds)); + dayContents.add(findBestPlace(sido, moodEmbed, 39, excludedIds)); + dayContents.add(findBestPlace(sido, moodEmbed, 12, excludedIds)); + dayContents.add(findBestPlace(sido, moodEmbed, 39, excludedIds)); + + if (totalDays > 1 && day < totalDays) { + dayContents.add(findBestPlace(sido, moodEmbed, 32, excludedIds)); + } + + return new AiplannerResponse.DayPlan(day, dayContents); + } + + private List getMoodEmbed(String mood) { + + Map requestBody = Map.of("text", mood); + + try { + log.info("{} 요청", clovaUrl); + Map responseBody = Objects.requireNonNull(restClient.post() + .body(requestBody) + .retrieve() + .body(Map.class)); + + log.info("responseBody : {}", responseBody); + + Map result = (Map) responseBody.get("result"); + + if(result != null) { + Object embeddingObject = result.get("embedding"); + log.info(embeddingObject.toString()); + + if (embeddingObject instanceof List) { + List embeddingList = (List) embeddingObject; + + return embeddingList.stream() + .map(Number::floatValue) + .toList(); + } + } + + } catch(RestClientResponseException e) { + log.error("Clova Embed Api 호출 오류 :: 상태코드 {} - 응답 :: {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new CatsgotogedogException(ErrorCode.CLOVA_HASHTAG_SERVER_ERROR); + } catch(Exception e) { + log.error("Mood 임베드 Api 호출 중 예상치 못한 오류 발생", e); + throw new CatsgotogedogException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + return Collections.emptyList(); + } + + private AiplannerResponse.ContentInfo findBestPlace( + Integer sidoCode, + List embededMood, + Integer contentTypeId, + Set excludedIds) { + + try { + List queryVector = embededMood.stream() + .map(Float::doubleValue) + .collect(Collectors.toList()); + + SearchRequest searchRequest = SearchRequest.of(s -> s + .index("content-embedding") + .size(100) + .query(q -> q + .scriptScore(ss -> ss + .query(query -> query + .bool(b -> b + .filter(f -> f.term(t -> t.field("sidoCode").value(sidoCode))) + .filter(f -> f.term(t -> t.field("contentTypeId").value(contentTypeId))) + ) + ) + .script(script -> script + .source("cosineSimilarity(params.queryVector, 'embedding') + 1.0") + .params("queryVector", JsonData.of(queryVector)) + ) + ) + ) + ); + + SearchResponse response = esClient.search(searchRequest, Map.class); + + for(Hit hit : response.hits().hits()) { + log.info("검색된 문서 ID: {}, Score: {}", hit.id(), hit.score()); + + Map sourceAsMap = hit.source(); + + if(sourceAsMap == null) { + log.warn("sourceAsMap이 null입니다."); + continue; + } + + log.info("문서 내용: {}", sourceAsMap); + + Integer contentId = (Integer) sourceAsMap.get("contentId"); + log.info("contentId: {}, excludedIds에 포함여부: {}", + contentId, excludedIds.contains(contentId)); + + if(contentId != null && !excludedIds.contains(contentId)) { + excludedIds.add(contentId); + Content content = contentRepository.findByContentId(contentId); + String rest = null; + + if(content.getContentTypeId() == 12) { + rest = sightsInformationRepository.findRestDateByContentId(content.getContentId()); + } else if(content.getContentTypeId() == 39) { + rest = restaurantInformationRepository.findRestDateByContentId(content.getContentId()); + } + + if(rest == null || rest.isEmpty()) { + rest = null; + } + + if(content == null) { + log.warn("contentId {}에 해당하는 Content를 찾을 수 없습니다.", contentId); + continue; + } + + log.info("추천 장소 선택됨: {}", content.getTitle()); + + return AiplannerResponse.ContentInfo.builder() + .contentId(content.getContentId()) + .image(content.getImage()) + .thumbImage(content.getThumbImage()) + .title(content.getTitle()) + .addr1(content.getAddr1()) + .addr2(content.getAddr2()) + .mapx(content.getMapx()) + .mapy(content.getMapy()) + .categoryId(content.getCategoryId()) + .rest(rest) + .build(); + } + } + + } catch (Exception e) { + log.error("Elasticsearch API 호출 오류 : {}", e.getMessage(), e); + throw new CatsgotogedogException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + throw new CatsgotogedogException(ErrorCode.NO_RECOMMEND_PLACES); + } + + private boolean isValidEmbeddingData(Content content) { + boolean hasTitle = content.getTitle() != null && !content.getTitle().trim().isEmpty(); + boolean hasOverview = content.getOverview() != null && !content.getOverview().trim().isEmpty(); + + if (!hasTitle || !hasOverview) { + log.debug("ContentId {} - title 또는 overview가 모두 없어 임베딩 제외", content.getContentId()); + return false; + } + + return true; + } + + private String createEmbeddingText(Content content) { + StringBuilder sb = new StringBuilder(); + if(content.getTitle() != null) { + sb.append("제목 : ").append(content.getTitle()).append(" "); + } + if(content.getOverview() != null) { + sb.append("소개 : ").append(content.getOverview()).append(" "); + } + return sb.toString().trim(); + } + + private List callClovaEmbedApi(String text) { + Map requestBody = Map.of("text", text); + + try { + Map responseBody = Objects.requireNonNull(restClient.post() + .body(requestBody) + .retrieve() + .body(Map.class)); + + Map result = (Map) responseBody.get("result"); + + if(result != null) { + Object embeddingObject = result.get("embedding"); + log.info(embeddingObject.toString()); + + if (embeddingObject instanceof List) { + List embeddingList = (List) embeddingObject; + + return embeddingList.stream() + .map(Number::floatValue) + .toList(); + } + } + + } catch(RestClientResponseException e) { + log.error("Clova Embed Api 호출 오류 :: 상태코드 {} - 응답 :: {}", e.getStatusCode(), e.getResponseBodyAsString()); + } catch(Exception e) { + log.error("{} Api 호출 중 예상치 못한 오류 발생", clovaUrl, e); + } + + return Collections.emptyList(); + } + + private void saveToElasticSearch(List embeddings) { + try { + contentEmbeddingRepository.saveAll(embeddings); + + log.info("{} 개의 ContentEmbedding 데이터 저장 완료", embeddings.size()); + }catch (Exception e) { + log.error("Elasticsearch 데이터 저장중 오류 발생 : ", e); + throw new RuntimeException("Elasticsaerch 저장중 오류 발생", e); + } + } + + private User validateUser(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java new file mode 100644 index 0000000..52e0c69 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryController.java @@ -0,0 +1,44 @@ +package com.swyp.catsgotogedog.category.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.swyp.catsgotogedog.category.service.CategoryService; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.review.service.ReviewReportService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/code") +public class CategoryController implements CategoryControllerSwagger { + + private final CategoryService categoryService; + private final ReviewReportService reviewReportService; + + @Override + @GetMapping("/regionCode") + public ResponseEntity> fetchRegionCodes( + @RequestParam(name = "시/도 코드", required = false) + Integer sidoCode, + @RequestParam(name = "시군구 코드", required = false) + Integer sigunguCode) { + return ResponseEntity.ok(CatsgotogedogApiResponse.success( + "지역 코드 조회 성공", + categoryService.findRegions(sidoCode, sigunguCode) + )); + } + + @Override + @GetMapping("/reasonCode") + public ResponseEntity> fetchResons() { + + return ResponseEntity.ok(CatsgotogedogApiResponse.success( + "신고 사유 목록 조회 성공", + reviewReportService.fetchReasons())); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java new file mode 100644 index 0000000..669b670 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/controller/CategoryControllerSwagger.java @@ -0,0 +1,59 @@ +package com.swyp.catsgotogedog.category.controller; + +import org.springframework.http.ResponseEntity; + +import com.swyp.catsgotogedog.category.domain.response.RegionHierarchyResponse; +import com.swyp.catsgotogedog.category.domain.response.SubRegionResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Code", description = "코드 관련 API") +public interface CategoryControllerSwagger { + + @Operation( + summary = "지역별 코드를 조회합니다.", + description = "입력 파라미터별로 동적으로 반환합니다. " + + "아무것도 입력하지 않을 경우 모든 sidoCode와 하위 sigunguCode까지 반환하며며," + + "sidoCode만 입력할 경우 해당 sidoCode와 하위 sigunguCode까지 반환합니다." + + "sigunguCode를 검색할 경우 sidoCode도 함께 필수로 입력해주어야 합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "지역 코드 조회 성공" + , content = @Content(schema = @Schema(implementation = RegionHierarchyResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchRegionCodes( + @Parameter(description = "시/도 코드", required = false) + Integer sidoCode, + @Parameter(description = "시군구 코드", required = false) + Integer sigunguCode + ); + + @Operation( + summary = "신고 사유 목록을 조회합니다.", + description = "리뷰 신고를 위한 신고 사유 목록입니다. 해당 사유 ID를 통해 리뷰 신고 처리를 진행해주세요" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사유 목록 조회 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "접근 권한이 없음, 다른 사람의 리뷰 삭제시" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchResons(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/entity/CategoryCode.java b/src/main/java/com/swyp/catsgotogedog/category/domain/entity/CategoryCode.java new file mode 100644 index 0000000..7017183 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/domain/entity/CategoryCode.java @@ -0,0 +1,29 @@ +package com.swyp.catsgotogedog.category.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "category_code") +@Builder +public class CategoryCode { + + @Id + @Column(name = "category_id", length = 30) + private String categoryId; + + @Column(name = "category_name", length = 50, nullable = false) + private String categoryName; + + @Column(name = "content_type_id", nullable = false) + private int contentTypeId; +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/category/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/response/RegionHierarchyResponse.java b/src/main/java/com/swyp/catsgotogedog/category/domain/response/RegionHierarchyResponse.java new file mode 100644 index 0000000..09affaa --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/domain/response/RegionHierarchyResponse.java @@ -0,0 +1,21 @@ +package com.swyp.catsgotogedog.category.domain.response; + +import java.util.List; +import java.util.stream.Collectors; + +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; + + +public record RegionHierarchyResponse ( + int sidoCode, + String regionName, + List subRegions +) { + public RegionHierarchyResponse(RegionCode regionCode, List subRegions) { + this( + regionCode.getSidoCode(), + regionCode.getRegionName(), + subRegions.stream().map(SubRegionResponse::new).collect(Collectors.toList()) + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/domain/response/SubRegionResponse.java b/src/main/java/com/swyp/catsgotogedog/category/domain/response/SubRegionResponse.java new file mode 100644 index 0000000..c11fc16 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/domain/response/SubRegionResponse.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.category.domain.response; + +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; + +public record SubRegionResponse( + int sigunguCode, + String regionName +){ + public SubRegionResponse(RegionCode regionCode) { + this(regionCode.getSigunguCode(), regionCode.getRegionName()); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java b/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java new file mode 100644 index 0000000..fe4341a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/repository/CategoryRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.category.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.category.domain.entity.CategoryCode; + +public interface CategoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java b/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java new file mode 100644 index 0000000..6ebbd82 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/category/service/CategoryService.java @@ -0,0 +1,65 @@ +package com.swyp.catsgotogedog.category.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.catsgotogedog.category.domain.response.SubRegionResponse; +import com.swyp.catsgotogedog.category.domain.response.RegionHierarchyResponse; +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; +import com.swyp.catsgotogedog.content.repository.RegionCodeRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CategoryService { + + private final RegionCodeRepository regionCodeRepository; + + public Object findRegions(Integer sidoCode, Integer sigunguCode) { + // sido, sigungu 함께 검색 + if(sidoCode != null && sigunguCode != null) { + RegionCode sido = regionCodeRepository.findBySidoCodeAndRegionLevel(sidoCode, 1); + if(sido != null) { + RegionCode sigungu = regionCodeRepository.findByParentCodeAndSigunguCode(sido.getRegionId(), sigunguCode) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.SIGUNGU_CODE_NOT_FOUND)); + + return new SubRegionResponse(sigungu); + }else + throw new CatsgotogedogException(ErrorCode.SIDO_CODE_NOT_FOUND); + // sidoCode만 입력 + } else if(sidoCode != null && sigunguCode == null) { + RegionCode sido = regionCodeRepository.findBySidoCodeAndRegionLevel(sidoCode, 1); + + List sigunguList = regionCodeRepository.findByParentCode(sido.getRegionId()); + + return new RegionHierarchyResponse(sido, sigunguList); + + // 모든 지역코드 + } else if(sidoCode == null && sigunguCode == null) { + List allRegions = regionCodeRepository.findAll(); + + Map> childrenMap = allRegions.stream() + .filter(region -> region.getRegionLevel() == 2) + .collect(Collectors.groupingBy(RegionCode::getParentCode)); + + return allRegions.stream() + .filter(region -> region.getRegionLevel() == 1) + .map(sido -> new RegionHierarchyResponse( + sido, + childrenMap.getOrDefault(sido.getSidoCode(), List.of()) + )).toList(); + } else { + throw new CatsgotogedogException(ErrorCode.SIGUNGU_NEEDS_WITH_SIDO_CODE); + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/CatsgotogedogAuthenticationEntryPoint.java b/src/main/java/com/swyp/catsgotogedog/common/config/CatsgotogedogAuthenticationEntryPoint.java new file mode 100644 index 0000000..e3409e9 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/CatsgotogedogAuthenticationEntryPoint.java @@ -0,0 +1,37 @@ +package com.swyp.catsgotogedog.common.config; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CatsgotogedogAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + CatsgotogedogApiResponse apiResponse = CatsgotogedogApiResponse.fail(ErrorCode.UNAUTHORIZED_ACCESS); + + log.info(authException.getMessage(), ErrorCode.UNAUTHORIZED_ACCESS.getMessage()); + objectMapper.writeValue(response.getWriter(), apiResponse); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/HttpClientConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/HttpClientConfig.java new file mode 100644 index 0000000..8ef12a2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/HttpClientConfig.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class HttpClientConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/PasswordEncoderConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..58e5b63 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/PasswordEncoderConfig.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/PerspectiveApiConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/PerspectiveApiConfig.java new file mode 100644 index 0000000..e164ff2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/PerspectiveApiConfig.java @@ -0,0 +1,20 @@ +package com.swyp.catsgotogedog.common.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Getter +@Configuration +public class PerspectiveApiConfig { + + @Value("${google.perspective.api.url}") + private String url; + + @Value("${google.perspective.api.key}") + private String apiKey; + + private final double nicknameThreshold = 0.6; + private final double petNameThreshold = 0.8; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java new file mode 100644 index 0000000..dc12f30 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/config/SecurityConfig.java @@ -0,0 +1,83 @@ +package com.swyp.catsgotogedog.common.config; + +import java.util.List; + +import com.swyp.catsgotogedog.common.security.filter.JwtTokenFilter; +import com.swyp.catsgotogedog.common.security.filter.OAuth2AutoLoginFilter; +import com.swyp.catsgotogedog.common.security.handler.OAuth2LoginSuccessHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + private final JwtTokenFilter jwtTokenFilter; + private final CatsgotogedogAuthenticationEntryPoint catsgotogedogAuthenticationEntryPoint; + private final OAuth2AutoLoginFilter oAuth2AutoLoginFilter; + + @Value("${allowed.origins.url}") + private String allowedOriginsUrl; + + @Value("${allowed.http.methods}") + private String allowedHttpMethods; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http.csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/oauth2/**", + "/login**", + "/error", + "/swagger-ui/**", + "/v3/api-docs/**", + "/api/user/reissue", + "/api/content/**", + // todo : 인증이 필요 없는 API에 대해 추가 작성 필요 + "/api/review/content/**", + "/api/code/**", + "/api/batch/**" + ).permitAll() + .anyRequest().authenticated()) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .exceptionHandling(eh -> eh.authenticationEntryPoint(catsgotogedogAuthenticationEntryPoint)) + .addFilterBefore(oAuth2AutoLoginFilter, OAuth2AuthorizationRequestRedirectFilter.class) + .oauth2Login(oauth -> oauth + .successHandler(oAuth2LoginSuccessHandler)) + .addFilterBefore(jwtTokenFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + private CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + List origins = List.of(allowedOriginsUrl.split(",")); + List methods = List.of(allowedHttpMethods.split(",")); + configuration.setAllowedOrigins(origins); + configuration.setAllowedMethods(methods); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/GoogleUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/GoogleUserInfo.java new file mode 100644 index 0000000..2a680c9 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/GoogleUserInfo.java @@ -0,0 +1,15 @@ +package com.swyp.catsgotogedog.common.oauth2; + +import java.util.Map; + +public record GoogleUserInfo(String id, String email, String name, String picture) { + + public static GoogleUserInfo of(Map attr) { + return new GoogleUserInfo( + (String) attr.get("sub"), + (String) attr.get("email"), + (String) attr.get("name"), + (String) attr.get("picture") + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java new file mode 100644 index 0000000..2ff9141 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/KakaoUserInfo.java @@ -0,0 +1,21 @@ +package com.swyp.catsgotogedog.common.oauth2; + +import java.util.Map; + + +public record KakaoUserInfo(String id, String email, String name, String profile_image) { + public static KakaoUserInfo of(Map attr) { + String id = String.valueOf(attr.get("id")); + + Map kakaoAccount = (Map) attr.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + String nickname = (String) profile.get("nickname"); + String profileImage = (String) profile.get("profile_image_url"); + String email = (String) kakaoAccount.get("email"); + + System.out.println("emial : "+email); + + return new KakaoUserInfo(id, email, nickname, profileImage); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/NaverUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/NaverUserInfo.java new file mode 100644 index 0000000..9e3366b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/NaverUserInfo.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.common.oauth2; + +import java.util.Map; + +public record NaverUserInfo(String id, String email, String name, String profileImage) { + + public static NaverUserInfo of(Map attr) { + Map res = (Map) attr.get("response"); + + return new NaverUserInfo( + (String) res.get("id"), + (String) res.get("email"), + (String) res.get("name"), + (String) res.get("profile_image") + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java b/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java new file mode 100644 index 0000000..f0badfe --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/oauth2/SocialUserInfo.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.common.oauth2; + +public record SocialUserInfo(String id, String email, String name, String profileImage) {} + diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java new file mode 100644 index 0000000..7445e3d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/JwtTokenFilter.java @@ -0,0 +1,73 @@ +package com.swyp.catsgotogedog.common.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenFilter implements Filter { + + private final JwtTokenUtil jwt; + private final ObjectMapper objectMapper; + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + + String bearer = request.getHeader("Authorization"); + + if (bearer != null && bearer.startsWith("Bearer ")) { + try { + String token = bearer.substring(7); + String sub = jwt.getSubject(token); + + var auth = new UsernamePasswordAuthenticationToken( + sub, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (ExpiredJwtException e) { + log.info("토큰이 만료되었습니다: {}", e.getMessage()); + sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "토큰이 만료되었습니다."); + return; + } catch (MalformedJwtException e) { + log.warn("잘못된 토큰 형식입니다: {}", e.getMessage()); + sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "잘못된 토큰 형식입니다."); + return; + } catch (Exception e) { + log.error("인증 처리 중 오류 발생: {}", e.getMessage(), e); + sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "인증 처리 중 오류가 발생했습니다."); + return; + } + } + chain.doFilter(req, res); + } + + private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException { + response.setCharacterEncoding("UTF-8"); + response.setStatus(status); + response.setContentType("application/json"); + + CatsgotogedogApiResponse errorResponse = CatsgotogedogApiResponse.fail(status, message); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + response.getWriter().flush(); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java b/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java new file mode 100644 index 0000000..319087c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/filter/OAuth2AutoLoginFilter.java @@ -0,0 +1,104 @@ +package com.swyp.catsgotogedog.common.security.filter; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponentsBuilder; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class OAuth2AutoLoginFilter extends OncePerRequestFilter { + + public final static String AUTO_LOGIN_PARAM = "autoLogin"; + + @Value("${frontend.base.url}") + private String frontend_base_url; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + if(request.getRequestURI().contains("/oauth2/authorization/")) { + String autoLoginParam = request.getParameter(AUTO_LOGIN_PARAM); + + String targetUrl = ""; + String refererHeader = request.getHeader("Referer"); + log.info("refererHeader :: {} ", refererHeader); + URL parsedUrl = null; + + + if(autoLoginParam == null) { + autoLoginParam = "false"; + } + + boolean autoLogin = "true".equalsIgnoreCase(autoLoginParam); + + // Referer 헤더를 통해 Redirect + if (refererHeader != null && !refererHeader.isEmpty()) { + try { + parsedUrl = new URL(refererHeader); + if (parsedUrl.getHost() != null && !parsedUrl.getHost().isEmpty()) { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(parsedUrl.getProtocol()) + .host(parsedUrl.getHost()); + if (parsedUrl.getPort() != -1) { + builder.port(parsedUrl.getPort()); + } + targetUrl = builder.path("/authredirect").build().toUriString(); + log.info("Referer 헤더를 통한 Redirect :: {}", targetUrl); + } + } catch (MalformedURLException e) { + log.warn("잘못된 형식의 URL :: {}", refererHeader, e); + } + } + + String requestURLString = request.getRequestURL().toString(); + + try { + URL requestURL = new URL(requestURLString); + String host = requestURL.getHost(); + String scheme = requestURL.getProtocol(); + int port = requestURL.getPort(); + + if (host != null && (host.equals("localhost") || host.equals("127.0.0.1"))) { + if (frontend_base_url != null && !frontend_base_url.isEmpty()) { + // Use the specifically configured localhost frontend URL + targetUrl = UriComponentsBuilder.fromUriString(frontend_base_url) + .path("/authredirect") + .build() + .toUriString(); + log.info("Referer Header가 존재하지 않아 frontend_base_url로 redirect :: {}", targetUrl); + } else { + UriComponentsBuilder builder = UriComponentsBuilder.newInstance() + .scheme(scheme) + .host(host); + if (port != -1) { + builder.port(port); + } + targetUrl = builder.path("/authredirect").build().toUriString(); + log.info("frontend_base_url이 존재하지 않아 request url로 강제 redirect :: {}", targetUrl); + } + } + } catch (MalformedURLException e) { + log.warn("잘못된 형식의 비 Referer URL :: {}", requestURLString, e); + } + + HttpSession session = request.getSession(true); + session.setAttribute(AUTO_LOGIN_PARAM, autoLogin); + session.setAttribute("targetUrl", targetUrl); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAccessDeniedHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAccessDeniedHandler.java new file mode 100644 index 0000000..284f0bb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAccessDeniedHandler.java @@ -0,0 +1,20 @@ +package com.swyp.catsgotogedog.common.security.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class MyAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied"); + } +} + diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAuthenticationEntryPoint.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAuthenticationEntryPoint.java new file mode 100644 index 0000000..61566c6 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/MyAuthenticationEntryPoint.java @@ -0,0 +1,19 @@ +package com.swyp.catsgotogedog.common.security.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..c3d0991 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/handler/OAuth2LoginSuccessHandler.java @@ -0,0 +1,101 @@ +package com.swyp.catsgotogedog.common.security.handler; + +import static com.swyp.catsgotogedog.common.security.filter.OAuth2AutoLoginFilter.*; + +import java.io.IOException; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.User.service.RefreshTokenService; +import com.swyp.catsgotogedog.common.security.service.PrincipalDetails; +import com.swyp.catsgotogedog.common.util.JwtTokenUtil; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler + implements AuthenticationSuccessHandler { + + private final JwtTokenUtil jwt; + private final RefreshTokenService rtService; + private final UserRepository userRepo; + + @Value("${jwt.refresh-expire-day}") + private int refreshDay; + + /** + * 최초 로그인 시 RefreshToken만 Cookie로 반환하도록 설정 + */ + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException { + + PrincipalDetails pd = (PrincipalDetails) auth.getPrincipal(); + String providerId = pd.getProviderId(); + + User user = userRepo.findByProviderId(providerId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + + //String access = jwt.createAccessToken(String.valueOf(user.getUserId()), user.getEmail()); + String refresh = jwt.createRefreshToken(String.valueOf(user.getUserId()), user.getEmail(), user.getDisplayName()); + + rtService.save(user, refresh, jwt.getRefreshTokenExpiry()); + + HttpSession session = request.getSession(false); + addRefreshTokenCookie(response, refresh, isAutoLogin(session)); + + if (session != null) { + log.info("targetUrl :: {}", session.getAttribute("targetUrl").toString()); + String targetUrl = session.getAttribute("targetUrl").toString(); + session.removeAttribute("targetUrl"); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + } + + private Boolean isAutoLogin(HttpSession session) { + if (session == null) { + return false; + } + + var autoLoginAttribute = session.getAttribute(AUTO_LOGIN_PARAM); + session.removeAttribute(AUTO_LOGIN_PARAM); + logger.info(autoLoginAttribute.equals(true)); + return autoLoginAttribute.equals(true); + } + + private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken, Boolean isAutoLogin) { + // Cookie refreshTokenCookie = new Cookie("X-Refresh-Token", refreshToken); + // refreshTokenCookie.setHttpOnly(true); + // refreshTokenCookie.setSecure(true); + // refreshTokenCookie.setPath("/"); + // if(isAutoLogin) { + // refreshTokenCookie.setMaxAge(refreshDay * 24 * 60 * 60); + // } + + StringBuilder cookieHeader = new StringBuilder(); + cookieHeader.append("X-Refresh-Token=").append(refreshToken) + .append("; HttpOnly") + .append("; Secure") + .append("; Path=/") + .append("; SameSite=None"); + if(isAutoLogin) { + cookieHeader.append("; Max-Age=").append(refreshDay * 24 * 60 * 60); + } + + response.addHeader("Set-Cookie", cookieHeader.toString()); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java new file mode 100644 index 0000000..f34cb10 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalDetails.java @@ -0,0 +1,69 @@ +package com.swyp.catsgotogedog.common.security.service; + + +import com.swyp.catsgotogedog.User.domain.entity.User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + + +public class PrincipalDetails implements OAuth2User, Authentication { + + private final User user; + private Map attributes; + + public PrincipalDetails(User user) { + this.user = user; + } + + public PrincipalDetails(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + + /* OAuth2User */ + @Override public Map getAttributes() { return attributes; } + + @Override public String getName() { return user.getDisplayName(); } + + /* Authentication */ + @Override public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + @Override + public Object getCredentials(){ + return null; + } + + @Override + public Object getDetails(){ + return null; + } + + @Override + public Object getPrincipal(){ + return this; + } + + @Override + public boolean isAuthenticated(){ + return true; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {} + + public String getProviderId() { + return user.getProviderId(); + } + + public String getDisplayName() { + return user.getDisplayName(); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java new file mode 100644 index 0000000..de03ad3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/security/service/PrincipalOauth2UserService.java @@ -0,0 +1,85 @@ +package com.swyp.catsgotogedog.common.security.service; + + + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.oauth2.KakaoUserInfo; +import com.swyp.catsgotogedog.common.oauth2.GoogleUserInfo; +import com.swyp.catsgotogedog.common.oauth2.NaverUserInfo; +import com.swyp.catsgotogedog.common.oauth2.SocialUserInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PrincipalOauth2UserService extends DefaultOAuth2UserService { + + private static final int RANDDOM_NUMBER_LENGTH = 6; + + private final UserRepository userRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest req) { + OAuth2User oAuth2User = super.loadUser(req); + String provider = req.getClientRegistration().getRegistrationId(); // google / kakao / naver + + SocialUserInfo info; + + switch (provider) { + case "kakao" -> { + KakaoUserInfo kakao = KakaoUserInfo.of(oAuth2User.getAttributes()); + info = new SocialUserInfo(kakao.id(), kakao.email(), kakao.name(), kakao.profile_image()); + } + case "naver" -> { + NaverUserInfo naver = NaverUserInfo.of(oAuth2User.getAttributes()); + info = new SocialUserInfo(naver.id(), naver.email(), naver.name(), naver.profileImage()); + } + case "google" -> { + GoogleUserInfo google = GoogleUserInfo.of(oAuth2User.getAttributes()); + info = new SocialUserInfo(google.id(), google.email(), google.name(), google.picture()); + } + default -> throw new IllegalArgumentException("지원하지 않는 소셜 로그인입니다: " + provider); + } + + User user = userRepository.findByProviderAndProviderId(provider, info.id()) + .orElseGet(() -> { + String display_name = info.name(); + while(userRepository.findByDisplayName(display_name).isPresent()) { + display_name = info.name() + generateRandomString(); + } + return userRepository.save( + User.builder() + .provider(provider) + .providerId(info.id()) + .email(info.email()) + .displayName(display_name) + .imageUrl(info.profileImage()) + .isActive(Boolean.TRUE) + .build() + ); + }); + + return new PrincipalDetails(user, oAuth2User.getAttributes()); + } + + // 랜덤 숫자(0~9) RANDDOM_NUMBER_LENGTH 값의 자리 만큼 반환 메서드 + private String generateRandomString() { + SecureRandom random = new SecureRandom(); + StringBuilder sb = new StringBuilder(RANDDOM_NUMBER_LENGTH); + for (int i = 0; i < RANDDOM_NUMBER_LENGTH; i++) { + sb.append(random.nextInt(10)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java new file mode 100644 index 0000000..718234d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/JwtTokenUtil.java @@ -0,0 +1,80 @@ +package com.swyp.catsgotogedog.common.util; + + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Date; + +@Component +public class JwtTokenUtil { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-expire-min}") + private long accessMin; + + @Value("${jwt.refresh-expire-day}") + private int refreshDay; + + private Key key; + + @PostConstruct + private void init() { + key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + } + + + public String createAccessToken(String sub, String email, String displayName) { + Date now = new Date(); + return Jwts.builder() + .setSubject(sub) + .claim("email", email) + .claim("displayName", displayName) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + accessMin * 60_000)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createRefreshToken(String sub, String email, String displayName) { + Date now = new Date(); + long refreshMs = Duration.ofDays(refreshDay).toMillis(); + return Jwts.builder() + .setSubject(sub) + .claim("email", email) + .claim("displayName", displayName) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + refreshMs)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String getSubject(String token) { + return Jwts.parserBuilder().setSigningKey(key).build() + .parseClaimsJws(token).getBody().getSubject(); + } + + public String getEmail(String token) { + return Jwts.parserBuilder().setSigningKey(key).build() + .parseClaimsJws(token).getBody().get("email", String.class); + } + + public String getDisplayName(String token) { + return Jwts.parserBuilder().setSigningKey(key).build() + .parseClaimsJws(token).getBody().get("displayName", String.class); + } + + public LocalDateTime getRefreshTokenExpiry() { + return LocalDateTime.now().plusDays(refreshDay); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java new file mode 100644 index 0000000..75c2a96 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ImageStorageService.java @@ -0,0 +1,168 @@ +package com.swyp.catsgotogedog.common.util.image.storage; + +import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; +import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; +import com.swyp.catsgotogedog.common.util.image.validator.ImageValidator; +import com.swyp.catsgotogedog.global.exception.*; +import com.swyp.catsgotogedog.global.exception.images.ImageNotFoundException; +import com.swyp.catsgotogedog.global.exception.images.ImageUploadException; +import com.swyp.catsgotogedog.global.exception.images.ImageLimitExceededException; +import io.awspring.cloud.s3.ObjectMetadata; +import io.awspring.cloud.s3.S3Resource; +import io.awspring.cloud.s3.S3Template; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + + +@Service +public class ImageStorageService { + + private final S3Template s3Template; + private final ObjectMetadataProvider objectMetadataProvider; + private final ImageValidator imageValidator; + private final String bucketName; + + private final int MAX_FILE_COUNT = 10; + + public ImageStorageService(S3Template s3Template, + ObjectMetadataProvider objectMetadataProvider, + ImageValidator imageValidator, + @Value("${spring.cloud.aws.s3.bucket}") String bucketName) { + + this.s3Template = s3Template; + this.objectMetadataProvider = objectMetadataProvider; + this.imageValidator = imageValidator; + this.bucketName = bucketName; + } + + /** + * 다중 이미지 업로드 + * @param files MultipartFile list + * @param uploadType 업로드 타입 (프로필 업로드, 리뷰 업로드 등 개수 제한용) + * @return List<ImageInfo> + */ + public List upload(List files, ImageUploadType uploadType) { + return upload(files, "", uploadType); + } + + /** + * 다중 이미지 업로드 + * @param files MultipartFile list + * @param path 업로드 경로 + * @param uploadType 업로드 타입 (프로필 업로드, 리뷰 업로드 등 개수 제한용) + * @return List<ImageInfo> + */ + public List upload(List files, String path, ImageUploadType uploadType) { + validateFiles(files,uploadType); + return files.stream() + .map(file -> doUpload(file, path)) + .collect(Collectors.toList()); + } + + /** + * 단일 이미지 업로드 + * @param file MultipartFile + * @param uploadType 업로드 타입 (프로필 업로드, 리뷰 업로드 등 개수 제한용) + * @return List<ImageInfo> + */ + public List upload(MultipartFile file, ImageUploadType uploadType) { + return upload(file, "", uploadType); + } + + /** + * 단일 이미지 업로드 + * @param file MultipartFile + * @param path 업로드 경로 + * @param uploadType 업로드 타입 (프로필 업로드, 리뷰 업로드 등 개수 제한용) + * @return List<ImageInfo> + */ + public List upload(MultipartFile file, String path, ImageUploadType uploadType) { + validateFile(file, uploadType); + return Collections.singletonList(doUpload(file, path)); + } + + /** + * 이미지 삭제 + * @param key image key + */ + public void delete(String key) { + validateKey(key); + doDelete(key); + } + + /** + * 다중 이미지 삭제 + * @param keys list of image keys + */ + public void delete(List keys) { + validateKeyList(keys); + keys.forEach(this::doDelete); + } + + private ImageInfo doUpload(MultipartFile file, String path) { + String key = genKey(file, path); + ObjectMetadata metadata = objectMetadataProvider.createPublicReadMetadata(file); + + try (InputStream stream = file.getInputStream()) { + S3Resource resource = s3Template.upload(bucketName, key, stream, metadata); + return new ImageInfo(resource.getFilename(), resource.getURL().toString()); + } catch (IOException e) { // Stream Exception + throw new ImageUploadException(ErrorCode.STREAM_IO_EXCEPTION); + } catch (Exception e) { // S3Exception 등 + throw new ImageUploadException(ErrorCode.IMAGE_UPLOAD_FAILED); + } + } + + // S3에서 객체 삭제 + private void doDelete(String key) { + s3Template.deleteObject(bucketName, key); + } + + // MIME 타입 검사 등 Tika를 사용한 바이너리 검사 기능 별도로 개발 필요 + private void validateFiles(List files, ImageUploadType uploadType) { + imageValidator.validate(files, uploadType); + } + + // MIME 타입 검사 등 Tika를 사용한 바이너리 검사 기능 별도로 개발 필요 + private void validateFile(MultipartFile file, ImageUploadType uploadType) { + imageValidator.validate(file, uploadType); + } + + private void validateKeyList(List keys) { + if (keys == null || keys.isEmpty()) { + throw new ImageKeyNotFoundException(ErrorCode.IMAGE_KEY_NOT_FOUND); + } + // 전체 키 리스트의 유효성을 먼저 검사 + if (keys.stream().anyMatch(key -> key == null || key.isBlank())) { + throw new ImageKeyNotFoundException(ErrorCode.IMAGE_KEY_NOT_FOUND); + } + } + + private void validateKey(String key) { + if (key == null || key.isBlank()) { + throw new ImageKeyNotFoundException(ErrorCode.IMAGE_KEY_NOT_FOUND); + } + } + + // 파일 이름과 UUID를 조합하여 고유한 키 생성 + private String genKey(MultipartFile file, String path) { + return path + UUID.randomUUID() + getFileExtension(file.getOriginalFilename()); + } + + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { + return ""; + } + return "." + filename.substring(lastDotIndex + 1).toLowerCase(); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ObjectMetadataProvider.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ObjectMetadataProvider.java new file mode 100644 index 0000000..1131fc7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/ObjectMetadataProvider.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.common.util.image.storage; + +import io.awspring.cloud.s3.ObjectMetadata; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.services.s3.model.ObjectCannedACL; + +@Component +public class ObjectMetadataProvider { + + public ObjectMetadata createPublicReadMetadata(MultipartFile file) { + return new ObjectMetadata.Builder() + .acl(ObjectCannedACL.PUBLIC_READ) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/dto/ImageInfo.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/dto/ImageInfo.java new file mode 100644 index 0000000..884e779 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/storage/dto/ImageInfo.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.common.util.image.storage.dto; + +public record ImageInfo(String key, String url) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java new file mode 100644 index 0000000..6460c72 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/DefaultImageValidator.java @@ -0,0 +1,131 @@ +package com.swyp.catsgotogedog.common.util.image.validator; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.images.ImageNotFoundException; +import com.swyp.catsgotogedog.global.exception.images.InvalidImageException; +import com.swyp.catsgotogedog.global.exception.images.ImageLimitExceededException; +import org.apache.tika.Tika; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +@Component +public class DefaultImageValidator implements ImageValidator { + + private final Tika tika = new Tika(); + private final Set allowedExtensions = Set.of("jpg", "jpeg", "png"); + private final Set allowedMimeTypes = Set.of("image/jpeg", "image/png"); + + /** + * 단일 이미지 파일 검증 + * @param file 검증할 이미지 파일 + * @param uploadType 업로드 타입 (최대 파일 수 제한용) + */ + @Override + public void validate(MultipartFile file, ImageUploadType uploadType) { + validateFileNotNull(file); + validateFileNameAndExtension(file); + validateImageFormat(file); + } + + /** + * 다중 이미지 파일 검증 + * @param files 검증할 이미지 파일 리스트 + * @param uploadType 업로드 타입 (최대 파일 수 제한용) + */ + @Override + public void validate(List files, ImageUploadType uploadType) { + validateFilesNotNull(files); + validateFileCount(files, uploadType); + + // 각 파일에 대해 개별 검증 수행 (하나라도 실패하면 전체 실패) + for (MultipartFile file : files) { + validate(file, uploadType); + } + } + + private void validateFileNotNull(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); + } + } + + private void validateFileNameAndExtension(MultipartFile file) { + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isBlank()) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_NAME); + } + + String baseFilename = getBaseFilename(originalFilename); + if (baseFilename == null || baseFilename.isBlank()) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_NAME); + } + + String extension = getFileExtension(originalFilename).toLowerCase(); + if (!allowedExtensions.contains(extension)) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_EXTENSION); + } + } + + private void validateImageFormat(MultipartFile file) { + try { + // Apache Tika를 사용하여 실제 파일 내용의 MIME 타입 검증 + String detectedMimeType = tika.detect(file.getInputStream()); + if (!allowedMimeTypes.contains(detectedMimeType)) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_FORMAT); + } + + // 추가 검증: 파일 확장자와 실제 MIME 타입 일치 여부 확인 + validateExtensionMimeTypeConsistency(file, detectedMimeType); + + } catch (IOException e) { + throw new InvalidImageException(ErrorCode.STREAM_IO_EXCEPTION); + } + } + + private void validateExtensionMimeTypeConsistency(MultipartFile file, String detectedMimeType) { + String originalFilename = file.getOriginalFilename(); + String extension = getFileExtension(originalFilename).toLowerCase(); + + boolean isConsistent = switch (extension) { + case "jpg", "jpeg" -> "image/jpeg".equals(detectedMimeType); + case "png" -> "image/png".equals(detectedMimeType); + default -> false; + }; + + if (!isConsistent) { + throw new InvalidImageException(ErrorCode.INVALID_IMAGE_FORMAT); + } + } + + private void validateFilesNotNull(List files) { + if (files == null || files.isEmpty()) { + throw new ImageNotFoundException(ErrorCode.IMAGE_NOT_FOUND); + } + } + + private void validateFileCount(List files, ImageUploadType uploadType) { + if (files.size() > uploadType.getMaxFiles()) { + throw new ImageLimitExceededException(ErrorCode.IMAGE_LIMIT_EXCEEDED); + } + } + + private String getBaseFilename(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { + return ""; + } + return filename.substring(0, lastDotIndex); + } + + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { + return ""; + } + return filename.substring(lastDotIndex + 1); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java new file mode 100644 index 0000000..d0fda74 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageUploadType.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.common.util.image.validator; + +import lombok.Getter; + +@Getter +public enum ImageUploadType { + PROFILE(1), + REVIEW(3), + GENERAL(10); + + private final int maxFiles; + + ImageUploadType(int maxFiles) { + this.maxFiles = maxFiles; + } + +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageValidator.java b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageValidator.java new file mode 100644 index 0000000..2f14bc9 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/image/validator/ImageValidator.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.common.util.image.validator; + +import org.springframework.web.multipart.MultipartFile; +import java.util.List; + +public interface ImageValidator { + + void validate(MultipartFile file, ImageUploadType uploadType); + + void validate(List files, ImageUploadType uploadType); +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiRequest.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiRequest.java new file mode 100644 index 0000000..41fc384 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiRequest.java @@ -0,0 +1,19 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Map; + +@Getter +@Setter +public class PerspectiveApiRequest { + + private Map comment; + private Map requestedAttributes; + + public PerspectiveApiRequest(String text) { + this.comment = Map.of("text", text); + this.requestedAttributes = Map.of("TOXICITY", Map.of()); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiResponse.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiResponse.java new file mode 100644 index 0000000..b825a5b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/PerspectiveApiResponse.java @@ -0,0 +1,61 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.PerspectiveApiException; +import lombok.extern.slf4j.Slf4j; + +/** + * Perspective API 응답 처리 유틸리티 + */ +@Slf4j +public class PerspectiveApiResponse { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Perspective API 응답 JSON에서 독성 점수를 추출 + * + * @param responseJson API 응답 JSON 문자열 + * @return 독성 점수 (0.0 ~ 1.0) + * @throws PerspectiveApiException API 응답 파싱 실패 시 + */ + public static double extractToxicityScore(String responseJson) { + try { + JsonNode response = objectMapper.readTree(responseJson); + + // attributeScores.TOXICITY.summaryScore.value 경로로 점수 추출 + JsonNode attributeScores = response.get("attributeScores"); + if (attributeScores == null) { + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + + JsonNode toxicity = attributeScores.get("TOXICITY"); + if (toxicity == null) { + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + + JsonNode summaryScore = toxicity.get("summaryScore"); + if (summaryScore == null) { + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + + JsonNode valueNode = summaryScore.get("value"); + if (valueNode == null) { + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + + double score = valueNode.asDouble(); + log.debug("독성 점수 추출 완료: {}", score); + + return score; + + } catch (PerspectiveApiException e) { + throw e; + } catch (Exception e) { + log.error("Perspective API 응답 파싱 실패: {}", e.getMessage(), e); + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_RESPONSE_ERROR); + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/ToxicityCheckResult.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/ToxicityCheckResult.java new file mode 100644 index 0000000..7e93296 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/dto/ToxicityCheckResult.java @@ -0,0 +1,23 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.dto; + +/** + * 독성 검사 결과를 나타내는 DTO 클래스 + * + * @param passed 검사 통과 여부 + * @param toxicityScore 독성 점수 (0.0 ~ 1.0) + * @param threshold 독성 점수 임계값 + * @param text 검사 대상 텍스트 + * @param errorMessage 오류 메시지 (검사 실패 시) + */ +public record ToxicityCheckResult(boolean passed, double toxicityScore, double threshold, String text, + String errorMessage) { + + public static ToxicityCheckResult success(String text, double toxicityScore, double threshold) { + boolean passed = toxicityScore <= threshold; + return new ToxicityCheckResult(passed, toxicityScore, threshold, text, null); + } + + public static ToxicityCheckResult failure(String text, double threshold, String errorMessage) { + return new ToxicityCheckResult(false, -1.0, threshold, text, errorMessage); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/PerspectiveApiService.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/PerspectiveApiService.java new file mode 100644 index 0000000..30dcc22 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/PerspectiveApiService.java @@ -0,0 +1,60 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.common.config.PerspectiveApiConfig; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.PerspectiveApiRequest; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.PerspectiveApiResponse; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.PerspectiveApiException; +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.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PerspectiveApiService { + + private final PerspectiveApiConfig config; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public double getToxicityScore(String text) { + // 1. 요청 객체 생성 + PerspectiveApiRequest request = new PerspectiveApiRequest(text); + + // 2. API 호출 + String response = callApi(request); + + // 3. 응답 파싱 + return parseResponse(response); + } + + private String callApi(PerspectiveApiRequest request) { + try { + String url = config.getUrl() + "?key=" + config.getApiKey(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String requestJson = objectMapper.writeValueAsString(request); + HttpEntity entity = new HttpEntity<>(requestJson, headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + return response.getBody(); + } catch (Exception e) { + log.error("Perspective API 요청 실패: {}", e.getMessage(), e); + throw new PerspectiveApiException(ErrorCode.PERSPECTIVE_API_REQUEST_ERROR); + } + } + + private double parseResponse(String responseJson) { + return PerspectiveApiResponse.extractToxicityScore(responseJson); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/ToxicityCheckService.java b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/ToxicityCheckService.java new file mode 100644 index 0000000..6dd6ca6 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/common/util/perspectiveApi/service/ToxicityCheckService.java @@ -0,0 +1,60 @@ +package com.swyp.catsgotogedog.common.util.perspectiveApi.service; + +import com.swyp.catsgotogedog.common.config.PerspectiveApiConfig; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.ToxicityCheckResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ToxicityCheckService { + + private final PerspectiveApiService perspectiveApiService; + private final PerspectiveApiConfig config; + + /** + * 닉네임의 독성 점수를 체크합니다. + * @param nickname 검사할 닉네임 + * @return ToxicityCheckResult 객체 + */ + public ToxicityCheckResult checkNickname(String nickname) { + return checkText(nickname, config.getNicknameThreshold()); + } + + /** + * 애완동물 이름의 독성 점수를 체크합니다. + * @param petName 검사할 애완동물 이름 + * @return ToxicityCheckResult 객체 + */ + public ToxicityCheckResult checkPetName(String petName) { + return checkText(petName, config.getPetNameThreshold()); + } + + /** + * 독성 점수를 체크합니다. + * @param text 검사할 텍스트 + * @param threshold 독성 점수 기준 + * @return ToxicityCheckResult 객체 + */ + public ToxicityCheckResult checkText(String text, double threshold) { + if (text == null || text.trim().isEmpty()) { + return ToxicityCheckResult.failure(text, threshold, "텍스트가 비어있습니다."); + } + try { + double score = perspectiveApiService.getToxicityScore(text.trim()); + + boolean passed = score <= threshold; + log.debug("독성 검사 - 텍스트: '{}', 점수: {}, 기준: {}, 통과: {}", + text, score, threshold, passed); + + return ToxicityCheckResult.success(text, score, threshold); + + } catch (Exception e) { + log.error("독성 검사 실패: {}", e.getMessage(), e); + return ToxicityCheckResult.failure(text, threshold, + "독성 검사 중 오류가 발생했습니다: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java new file mode 100644 index 0000000..f0629d5 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentController.java @@ -0,0 +1,121 @@ +package com.swyp.catsgotogedog.content.controller; + +import com.swyp.catsgotogedog.content.domain.request.ContentRequest; +import com.swyp.catsgotogedog.content.domain.response.AiRecommendsResponse; +import com.swyp.catsgotogedog.content.domain.response.ContentResponse; +import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; +import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; +import com.swyp.catsgotogedog.content.service.AiRecommendsService; +import com.swyp.catsgotogedog.content.service.ContentSearchService; +import com.swyp.catsgotogedog.content.service.ContentService; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import org.apache.commons.lang3.math.NumberUtils; +import org.flywaydb.core.internal.util.StringUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/content") +public class ContentController implements ContentControllerSwagger{ + private final ContentService contentService; + private final ContentSearchService contentSearchService; + private final AiRecommendsService aiRecommandService; + + @GetMapping("/search") + public ResponseEntity> search( + @RequestParam(required = false) String title, + @RequestParam(required = false) String sido, + @RequestParam(required = false) List sigungu, + @RequestParam(required = false) Integer contentTypeId, + @AuthenticationPrincipal String principal) { + + String userId = null; + if (StringUtils.hasText(principal) && NumberUtils.isCreatable(principal)) { + userId = principal; + } + + List list = contentSearchService.search(title, sido, sigungu, contentTypeId, userId); + + return list.isEmpty() + ? ResponseEntity.noContent().build() // 204 + : ResponseEntity.ok(list); // 200 + } + + @PostMapping("/save") + ResponseEntity saveContent(@RequestBody ContentRequest request) { + contentService.saveContent(request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/savelist") + public ResponseEntity saveList(@RequestBody List requests) { + requests.forEach(contentService::saveContent); + return ResponseEntity.ok().build(); + } + + @GetMapping("/placedetail") + public ResponseEntity getPlaceDetail( + @RequestParam int contentId, + @AuthenticationPrincipal String principal){ + + String userId = null; + if (StringUtils.hasText(principal) && NumberUtils.isCreatable(principal)) { + userId = principal; + } + + PlaceDetailResponse placeDetailResponse = contentService.getPlaceDetail(contentId,userId); + return ResponseEntity.ok(placeDetailResponse); + } + + @GetMapping("/recent") + public ResponseEntity> getRecentViews(@AuthenticationPrincipal String userId) { + List recent = contentService.getRecentViews(userId); + return ResponseEntity.ok().body(recent); + } + + + @PostMapping("/wish-check") + public ResponseEntity checkWish( + @AuthenticationPrincipal String userId, + @RequestParam int contentId + ) { + boolean checkWish = contentService.checkWish(userId, contentId); + return ResponseEntity.ok(Map.of("checkWish", checkWish)); + } + + @PostMapping("/visited-check") + public ResponseEntity checkVisited( + @AuthenticationPrincipal String userId, + @RequestParam int contentId + ) { + boolean visited = contentService.checkVisited(userId, contentId); + return ResponseEntity.ok(Map.of("visited", visited)); + + } + @GetMapping("/ai/recommends") + public ResponseEntity>> recommendContents( + @AuthenticationPrincipal String userId) { + List recommendations = aiRecommandService.recommends(userId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("AI 추천 장소 조회 성공", recommendations)); + } + + @Override + @PostMapping("/recent/{contentId}") + public ResponseEntity> lastViewedHistory( + @AuthenticationPrincipal String userId, + @PathVariable int contentId + ) { + contentService.saveLastViewedContent(userId, contentId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("최근 본 장소 저장 성공", null) + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java new file mode 100644 index 0000000..2483e5c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentControllerSwagger.java @@ -0,0 +1,158 @@ +package com.swyp.catsgotogedog.content.controller; + +import com.swyp.catsgotogedog.content.domain.response.AiRecommendsResponse; +import com.swyp.catsgotogedog.content.domain.response.ContentResponse; +import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; +import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@Tag(name = "Content", description = "컨텐츠 (관광지, 숙소, 음식점, 축제/공연/행사) 관련 API") +public interface ContentControllerSwagger { + + @Operation( + summary = "컨텐츠 검색", + description = "제목, 시/도, 시/군/구, 컨텐츠 유형으로 장소를 검색합니다. " + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "검색 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ContentResponse.class)))), + @ApiResponse(responseCode = "204", description = "검색 결과 없음"), + @ApiResponse(responseCode = "400", description = "요청 파라미터가 유효하지 않음") + }) + ResponseEntity> search( + @Parameter(description = "장소 검색어", required = false) + @RequestParam(required = false) String title, + + @Parameter(description = "시/도 코드", required = false) + @RequestParam(required = false) String sido, + + @Parameter(description = "시/군/구 코드", required = false) + @RequestParam(required = false) List sigunguCode, + + @Parameter(description = "컨텐츠 유형 ID", required = false) + @RequestParam(required = false) Integer contentTypeId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String principal + )throws IOException; + + @Operation( + summary = "공간 상세 조회", + description = "contentId로 장소 상세 정보를 조회" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = PlaceDetailResponse.class))), + @ApiResponse(responseCode = "404", description = "해당 contentId에 대한 데이터 없음") + }) + ResponseEntity getPlaceDetail( + @Parameter(description = "조회할 컨텐츠 ID", required = true) + @RequestParam int contentId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String principal + ); + + @Operation( + summary = "찜 체크 기능", + description = "로그인된 사용자의 해당 콘텐츠 찜 상태를 설정 또는 해제. " + + "이미 찜돼 있으면 해제(false), 아니면 찜(true). " + + "비회원이거나 인증 정보가 없으면 false 반환", + security = { @SecurityRequirement(name = "bearer-key") } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "찜 처리 결과 반환", + content = @Content(schema = @Schema(implementation = Map.class)) + ), + @ApiResponse(responseCode = "401", description = "인증 필요"), + @ApiResponse(responseCode = "404", description = "해당 사용자 또는 콘텐츠 없음") + }) + @PostMapping("/wish-check") + ResponseEntity checkWish( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @Parameter(description = "컨텐츠 ID", required = true) + @RequestParam int contentId + ); + + @Operation( + summary = "방문 여부 체크", + description = "로그인된 사용자의 해당 콘텐츠 방문 여부를 체크 또는 해제, " + + "체크돼 있으면 해제하고(false), 체크돼 있지 않으면 체크합니다(true). " + + "비회원인 경우 아무 동작 없이 false 반환", + security = { @SecurityRequirement(name = "bearer-key") } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "체크 결과", + content = @Content(schema = @Schema(implementation = Map.class)) + ), + @ApiResponse(responseCode = "401", description = "인증 필요"), + @ApiResponse(responseCode = "404", description = "해당 콘텐츠 또는 사용자 없음") + }) + @GetMapping("/visited-check") + ResponseEntity checkVisited( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @Parameter(description = "장소 ID", required = true) + @RequestParam int contentId + ); + + @Operation( + summary = "AI 추천 장소 조회", + description = "AI가 추천하는 반려동물 친화적 여행지 목록을 조회합니다. " + + "사용자의 찜 목록 데이터를 기반으로 개인화된 추천을 제공하며, " + + "데이터가 부족한 경우 랜덤 추천을 제공합니다. " + + "비회원도 이용 가능하지만 개인화되지 않은 일반 추천을 받게 됩니다.", + security = { @SecurityRequirement(name = "bearer-key") } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "AI 추천 장소 조회 성공"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @GetMapping("/ai/recommends") + ResponseEntity>> recommendContents( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId + ); + + + @Operation( + summary = "최근 본 장소 저장", + description = "사용자 인증을 통해 최근 본 장소를 저장합니다.") + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "최근 본 장소 저장 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> lastViewedHistory( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @PathVariable int contentId + ); + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java new file mode 100644 index 0000000..1a9aa6c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankController.java @@ -0,0 +1,30 @@ +package com.swyp.catsgotogedog.content.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.swyp.catsgotogedog.content.service.ContentRankService; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/content") +public class ContentRankController implements ContentRankControllerSwagger { + + private final ContentRankService contentRankService; + + @Override + @GetMapping("/rank") + public ResponseEntity> fetchContentRank( + @AuthenticationPrincipal String userId + ) { + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("조회 성공", contentRankService.fetchContentRank(userId)) + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java new file mode 100644 index 0000000..8fcf766 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/controller/ContentRankControllerSwagger.java @@ -0,0 +1,38 @@ +package com.swyp.catsgotogedog.content.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestParam; + +import com.swyp.catsgotogedog.content.domain.response.ContentRankResponse; +import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Content", description = "컨텐츠 (관광지, 숙소, 음식점, 축제/공연/행사) 관련 API") +public interface ContentRankControllerSwagger { + + @Operation( + summary = "인기 장소 조회", + description = """ + 24시간 동안 조회수가 가장 많은 장소를 반환합니다. + """ + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = ContentRankResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + ResponseEntity> fetchContentRank( + @AuthenticationPrincipal String userId + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/AiRecommends.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/AiRecommends.java new file mode 100644 index 0000000..900f073 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/AiRecommends.java @@ -0,0 +1,30 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "ai_recommends") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AiRecommends { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "recommends_id") + private Integer recommendsId; + + @Column(name = "content_id", nullable = false) + private Integer contentId; + + @Column(name = "message", columnDefinition = "TEXT") + private String message; + + @Column(name = "image_url") + private String imageUrl; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java new file mode 100644 index 0000000..63dd62e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Content.java @@ -0,0 +1,90 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "content") +@ToString +@Builder +public class Content { + @Id + @Column(name = "content_id", updatable = false) + private int contentId; + + @Column(name = "content_type_id", nullable = false) + private int contentTypeId; + + @Column(name = "category_id") + private String categoryId; + + @Column(name = "sido_code") + private int sidoCode; + + @Column(name = "sigungu_code") + private int sigunguCode; + + @Column(name = "addr1") + private String addr1; + + @Column(name = "addr2") + private String addr2; + + @Column(name = "image") + private String image; + + @Column(name = "thumb_image") + private String thumbImage; + + @Column(name = "copyright") + private String copyright; + + @Column(name = "mapx") + private double mapx; + + @Column(name = "mapy") + private double mapy; + + @Column(name = "mlevel") + private int mLevel; + + @Column(name = "tel") + private String tel; + + @Column(name = "title") + private String title; + + @Column(name = "zipcode") + private int zipCode; + + @Lob + @Column(name = "overview") + private String overview; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java new file mode 100644 index 0000000..69a1303 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentDocument.java @@ -0,0 +1,41 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import org.springframework.data.annotation.Id; +import lombok.*; +import org.springframework.data.elasticsearch.annotations.*; + +@Getter +@Document(indexName = "content", createIndex = true) +@Setting(settingPath = "elasticsearch/search-setting.json") +@Mapping(mappingPath = "elasticsearch/search-mapping.json") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ContentDocument { + + @Id + @Field(type= FieldType.Integer) + private int contentId; + + private String categoryId; + + private int sidoCode; + + private int sigunguCode; + + private String title; + + private int contentTypeId; + + public static ContentDocument from(Content content){ + return ContentDocument.builder() + .contentId(content.getContentId()) + .categoryId(content.getCategoryId()) + .sidoCode(content.getSidoCode()) + .sigunguCode(content.getSigunguCode()) + .title(content.getTitle()) + .contentTypeId(content.getContentTypeId()) + .build(); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java new file mode 100644 index 0000000..1f8c1b7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentImage.java @@ -0,0 +1,47 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "content_image") +@Builder +public class ContentImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "content_image_id", updatable = false) + private int contentImageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "image_url") + private String imageUrl; + + @Column(name = "image_filename") + private String imageFilename; + + @Column(name = "small_image_url") + private String smallImageUrl; + + @Column(name = "small_image_filename") + private String smallImageFilename; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java new file mode 100644 index 0000000..c004d7e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ContentWish.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ContentWish { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int wishId; + + private int userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java new file mode 100644 index 0000000..6c02f26 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/Hashtag.java @@ -0,0 +1,31 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Hashtag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "hashtag_id") + private int hashtagId; + + @Column(name = "content_id") + private int contentId; + + @Column(name = "content") + private String content; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java new file mode 100644 index 0000000..381ddcb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/RegionCode.java @@ -0,0 +1,47 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import java.util.List; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "region_code") +@Builder +public class RegionCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "region_id") + private int regionId; + + @Column(name = "region_name", nullable = false) + private String regionName; + + @Column(name = "sido_code") + private Integer sidoCode; + + @Column(name = "sigungu_code") + private Integer sigunguCode; + + @Column(name = "parent_code") + private Integer parentCode; + + @Column(name = "region_level") + private int regionLevel; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java new file mode 100644 index 0000000..902ec1d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewLog.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ViewLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "view_id") + private Long viewId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "content_id") + private Content content; + + @CreatedDate + @Column(name = "viewed_at", + nullable = false, + updatable = false) + private LocalDateTime viewedAt; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java new file mode 100644 index 0000000..33259ea --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/ViewTotal.java @@ -0,0 +1,29 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.LastModifiedDate; + +import java.time.LocalDateTime; + +@Entity +@Getter +public class ViewTotal { + @Id + @Column(name = "content_id") + private int contentId; + + @MapsId + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "total_view", nullable = false) + private int totalView; + + @LastModifiedDate + @Column(name = "updated_at", + nullable = false) + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java new file mode 100644 index 0000000..6a05f40 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/VisitHistory.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.content.domain.entity; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VisitHistory { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "visit_id") + private Long visitId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "content_id") + private Content content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/FestivalInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/FestivalInformation.java new file mode 100644 index 0000000..c2de73d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/FestivalInformation.java @@ -0,0 +1,88 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.information; + +import java.time.LocalDate; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "festival_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FestivalInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "festival_id") + private Integer festivalId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "age_limit") + private String ageLimit; + + @Column(name = "booking_place") + private String bookingPlace; + + @Column(name = "discount_info") + private String discountInfo; + + @Column(name = "event_start_date") + private LocalDate eventStartDate; + + @Column(name = "event_end_date") + private LocalDate eventEndDate; + + @Column(name = "event_homepage") + private String eventHomepage; + + @Column(name = "event_place") + private String eventPlace; + + @Column(name = "place_info") + private String placeInfo; + + @Column(name = "play_time") + private String playTime; + + @Column(name = "program") + private String program; + + @Column(name = "spend_time") + private String spendTime; + + @Column(name = "organizer") + private String organizer; + + @Column(name = "organizer_tel") + private String organizerTel; + + @Column(name = "supervisor") + private String supervisor; + + @Column(name = "supervisor_tel") + private String supervisorTel; + + @Column(name = "sub_event") + private String subEvent; + + @Column(name = "fee_info") + private String feeInfo; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java new file mode 100644 index 0000000..64f35ba --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/LodgeInformation.java @@ -0,0 +1,104 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.information; + +import java.time.LocalTime; + + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "lodge_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LodgeInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "lodge_id") + private Integer lodgeId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "capacity_count") + private Integer capacityCount; + + private Boolean goodstay; + + private Boolean benikia; + + @Column(name = "check_in_time") + private String checkInTime; + + @Column(name = "check_out_time") + private String checkOutTime; + + @Column(name = "cooking", length = 50) + private String cooking; + + @Column(name = "foodplace", length = 50) + private String foodplace; + + private Boolean hanok; + + @Column(name = "information", length = 50) + private String information; + + @Column(name = "parking", length = 50) + private String parking; + + @Column(name = "pickup_service") + private String pickupService; + + @Column(name = "room_count") + private Integer roomCount; + + @Column(name = "reservation_info", length = 30) + private String reservationInfo; + + @Column(name = "reservation_url", length = 50) + private String reservationUrl; + + @Column(name = "room_type", length = 30) + private String roomType; + + @Column(name = "scale", length = 30) + private String scale; + + @Column(name = "sub_facility", length = 50) + private String subFacility; + + private Boolean barbecue; + private Boolean beauty; + private Boolean beverage; + private Boolean bicycle; + private Boolean campfire; + private Boolean fitness; + private Boolean karaoke; + @Column(name = "public_bath") + private Boolean publicBath; + @Column(name = "public_pc_room") + private Boolean publicPcRoom; + private Boolean sauna; + private Boolean seminar; + private Boolean sports; + + @Column(name = "refund_regulation", length = 100) + private String refundRegulation; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java new file mode 100644 index 0000000..25d7c7f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/RestaurantInformation.java @@ -0,0 +1,77 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.information; + +import java.time.LocalDate; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "restaurant_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RestaurantInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "restaurant_id") + private Integer restaurantId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "chk_creditcard") + private String chkCreditcard; + + @Column(name = "discount_info") + private String discountInfo; + + @Column(name = "signature_menu") + private String signatureMenu; + + @Column(name = "information") + private String information; + + @Column(name = "kids_facility") + private Boolean kidsFacility; + + @Column(name = "open_date") + private LocalDate openDate; + + @Column(name = "open_time") + private String openTime; + + @Column(name = "takeout") + private String takeout; + + @Column(name = "parking") + private String parking; + + @Column(name = "reservation") + private String reservation; + + @Column(name = "rest_date") + private String restDate; + + private Integer scale; + private String seat; + private Boolean smoking; + + @Column(name = "treat_menu") + private String treatMenu; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/SightsInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/SightsInformation.java new file mode 100644 index 0000000..bf051d8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/information/SightsInformation.java @@ -0,0 +1,64 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.information; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDate; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +@Entity +@Table(name = "sights_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SightsInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "sights_id") + private Integer sightsId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "content_type_id") + private Integer contentTypeId; + + @Column(name = "accom_count") + private Integer accomCount; + + @Column(name = "chk_creditcard") + private String chkCreditcard; + + @Column(name = "exp_age_range") + private String expAgeRange; + + @Column(name = "exp_guide") + private String expGuide; + + @Column(name = "info_center") + private String infoCenter; + + @Column(name = "open_date") + private LocalDate openDate; + + @Column(name = "parking") + private String parking; + + @Column(name = "rest_date") + private String restDate; + + @Column(name = "use_season") + private String useSeason; + + @Column(name = "use_time") + private String useTime; + + private Boolean heritage1; + private Boolean heritage2; + private Boolean heritage3; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformation.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformation.java new file mode 100644 index 0000000..eedef13 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformation.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.recur; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "recur_information") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecurInformation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int recurId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + private String infoName; + + @Column(columnDefinition = "TEXT") + private String infoText; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java new file mode 100644 index 0000000..0c5d8e7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoom.java @@ -0,0 +1,75 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.recur; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "recur_information_room") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecurInformationRoom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer recurRoomId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + private String roomTitle; + private Integer roomSize1; + private Integer roomCount; + private Integer roomBaseCount; + private Integer roomMaxCount; + private Integer offSeasonWeekMinFee; + private Integer offSeasonWeekendMinFee; + private Integer peakSeasonWeekMinFee; + private Integer peakSeasonWeekendMinFee; + + @Column(columnDefinition = "TEXT") + private String roomIntro; + + @Column(name = "room_bath_facility") + private Boolean roomBathFacility; // 오타 수정: facility + private Boolean roomBath; + private Boolean roomHomeTheater; + private Boolean roomAircondition; + private Boolean roomTv; + private Boolean roomPc; + private Boolean roomCable; + private Boolean roomInternet; + private Boolean roomRefrigerator; + private Boolean roomToiletries; + private Boolean roomSofa; + private Boolean roomCook; + private Boolean roomTable; + private Boolean roomHairdryer; + + @Column(precision = 10, scale = 2) + private BigDecimal roomSize2; + + @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List images; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoomImage.java b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoomImage.java new file mode 100644 index 0000000..100b3b2 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/entity/batch/recur/RecurInformationRoomImage.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.content.domain.entity.batch.recur; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "recur_information_room_image") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecurInformationRoomImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer recurRoomImageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recur_room_id") + private RecurInformationRoom room; + + private String imageUrl; + private String imageFilename; + private String imageAlt; + + @Column(length = 50) + private String imageCopyright; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaApiRequest.java b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaApiRequest.java new file mode 100644 index 0000000..02dcd4a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaApiRequest.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.content.domain.request; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ClovaApiRequest { + private List messages; + private double topP = 0.8; + private int topK = 40; + private int maxTokens = 256; + private double temperature = 0.3; + private double repetitionPenalty = 1.1; + private List stop = new ArrayList<>(); + private int seed = 0; + private boolean includeAiFilters = true; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Message { + private String role; + private List content; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Content { + private String type; + private String text; + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaGenerationRequest.java b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaGenerationRequest.java new file mode 100644 index 0000000..e2932a7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ClovaGenerationRequest.java @@ -0,0 +1,13 @@ +package com.swyp.catsgotogedog.content.domain.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ClovaGenerationRequest { + private String title; + private String content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java new file mode 100644 index 0000000..ac013ab --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/request/ContentRequest.java @@ -0,0 +1,23 @@ +package com.swyp.catsgotogedog.content.domain.request; + +import lombok.Getter; + +import java.math.BigDecimal; + +@Getter +public class ContentRequest { + private int contentId; + private String categoryId; + private String addr1; + private String addr2; + private String image; + private String thumbImage; + private String copyright; + private double mapx; + private double mapy; + private int mlevel; + private String tel; + private String title; + private int zipcode; + private int contentTypeId; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java new file mode 100644 index 0000000..402e150 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/AiRecommendsResponse.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import com.swyp.catsgotogedog.content.domain.entity.AiRecommends; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class AiRecommendsResponse { + private int contentId; + private String message; + private String imageUrl; + + public static AiRecommendsResponse of(AiRecommends aiRecommends) { + return new AiRecommendsResponse( + aiRecommends.getContentId(), + aiRecommends.getMessage(), + aiRecommends.getImageUrl() + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentImageResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentImageResponse.java new file mode 100644 index 0000000..8417f20 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentImageResponse.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.content.domain.response; + +public record ContentImageResponse( + String imageUrl, + String imageFilename +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java new file mode 100644 index 0000000..de0d401 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentRankResponse.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import java.util.List; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ContentRankResponse { + private int contentId; + private String title; + private String image; + private String thumbImage; + private int contentTypeId; + private double mapx; + private double mapy; + private List hashtag; + private String categoryId; + private double avgScore; + private String restDate; + private int ranking; + private boolean wishData; + private RegionCodeResponse regionName; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java new file mode 100644 index 0000000..7f7aa86 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/ContentResponse.java @@ -0,0 +1,72 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class ContentResponse { + private int contentId; + private String title; + private String addr1; + private String addr2; + private String image; + private String thumbImage; + private String categoryId; + private int contentTypeId; + private String copyright; + private double mapx; + private double mapy; + private int mlevel; + private String tel; + private int zipcode; + + private Double avgScore; + private boolean wishData; + private RegionCodeResponse regionName; + private List hashtag; + private String restDate; + private int totalView; + private int wishCnt; + private LocalDateTime createdAt; + + public static ContentResponse from( + Content c, + Double avgScore, + boolean wishData, + RegionCodeResponse regionName, + List hashtag, + String restDate, + int totalView, + int wishCnt){ + + return ContentResponse.builder() + .contentId(c.getContentId()) + .title(c.getTitle()) + .addr1(c.getAddr1()) + .addr2(c.getAddr2()) + .image(c.getImage()) + .thumbImage(c.getThumbImage()) + .categoryId(c.getCategoryId()) + .contentTypeId(c.getContentTypeId()) + .copyright(c.getCopyright()) + .mapx(c.getMapx()) + .mapy(c.getMapy()) + .mlevel(c.getMLevel()) + .tel(c.getTel()) + .zipcode(c.getZipCode()) + .avgScore(avgScore) + .wishData(wishData) + .regionName(regionName) + .hashtag(hashtag) + .restDate(restDate) + .totalView(totalView) + .wishCnt(wishCnt) + .createdAt(c.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/HashtagClovaResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/HashtagClovaResponse.java new file mode 100644 index 0000000..30413d8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/HashtagClovaResponse.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HashtagClovaResponse { + private List hashtags; +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/LastViewHistoryResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/LastViewHistoryResponse.java new file mode 100644 index 0000000..330cea0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/LastViewHistoryResponse.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +public record LastViewHistoryResponse( + int contentId, + String title, + String thumbImage) { + + public static LastViewHistoryResponse from(Content c) { + return new LastViewHistoryResponse( + c.getContentId(), + c.getTitle(), + c.getThumbImage() + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java new file mode 100644 index 0000000..cd05bc8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/PlaceDetailResponse.java @@ -0,0 +1,83 @@ +package com.swyp.catsgotogedog.content.domain.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; +import com.swyp.catsgotogedog.pet.domain.response.PetGuideResponse; +import lombok.Builder; + +import java.util.List; +import java.util.Map; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; + +@JsonInclude(NON_NULL) +@Builder +public record PlaceDetailResponse( + int contentId, + String title, + String addr1, + String addr2, + String image, + String thumbImage, + String categoryId, + int contentTypeId, + String copyright, + double mapx, + double mapy, + int mlevel, + String tel, + int zipcode, + Double avgScore, + boolean wishData, + int wishCnt, + boolean visited, + int totalView, + String overview, + List detailImage, + PetGuide petGuide, + String restDate, + Map additionalInformation +) { + + public static PlaceDetailResponse from( + Content c, + Double avgScore, + boolean wishData, + int wishCnt, + boolean visited, + int totalView, + List detailImage, + PetGuide petGuide, + String restDate, + Map additionalInformation + ){ + + return PlaceDetailResponse.builder() + .contentId(c.getContentId()) + .title(c.getTitle()) + .addr1(c.getAddr1()) + .addr2(c.getAddr2()) + .image(c.getImage()) + .thumbImage(c.getThumbImage()) + .categoryId(c.getCategoryId()) + .contentTypeId(c.getContentTypeId()) + .copyright(c.getCopyright()) + .mapx(c.getMapx()) + .mapy(c.getMapy()) + .mlevel(c.getMLevel()) + .tel(c.getTel()) + .zipcode(c.getZipCode()) + .avgScore(avgScore) + .wishData(wishData) + .wishCnt(wishCnt) + .visited(visited) + .totalView(totalView) + .overview(c.getOverview()) + .detailImage(detailImage) + .petGuide(petGuide) + .restDate(restDate) + .additionalInformation(additionalInformation) + .build(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/domain/response/RegionCodeResponse.java b/src/main/java/com/swyp/catsgotogedog/content/domain/response/RegionCodeResponse.java new file mode 100644 index 0000000..17b764c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/domain/response/RegionCodeResponse.java @@ -0,0 +1,4 @@ +package com.swyp.catsgotogedog.content.domain.response; + +public record RegionCodeResponse(String sidoName, String sigunguName) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/AiRecommendsRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/AiRecommendsRepository.java new file mode 100644 index 0000000..0e1138d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/AiRecommendsRepository.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.AiRecommends; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AiRecommendsRepository extends JpaRepository { + + @Query(value = "SELECT * FROM ai_recommends ORDER BY RAND() LIMIT 5", nativeQuery = true) + List findRandomRecommends(); + + long count(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java new file mode 100644 index 0000000..eaa10d4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentElasticRepository.java @@ -0,0 +1,10 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.ContentDocument; +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +import java.util.List; + +public interface ContentElasticRepository extends ElasticsearchRepository { + List findByTitleContaining(String title); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java new file mode 100644 index 0000000..8c8266a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentImageRepository.java @@ -0,0 +1,13 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import org.springframework.data.jpa.repository.JpaRepository; +import com.swyp.catsgotogedog.content.domain.entity.ContentImage; + +import java.util.List; + +public interface ContentImageRepository extends JpaRepository { + ContentImage findByContent_ContentId(int contentId); + + List findAllByContent(Content content); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java new file mode 100644 index 0000000..7162cdf --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentRepository.java @@ -0,0 +1,50 @@ +package com.swyp.catsgotogedog.content.repository; + +import java.util.List; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +public interface ContentRepository extends JpaRepository { + Content findByContentId(int contentId); + + @Query(value = + "SELECT c.* FROM content c " + + "LEFT JOIN hashtag h ON c.content_id = h.content_id " + + "WHERE h.content_id IS NULL", nativeQuery = true) + List findContentsWithoutHashtags(); + + List findAllByContentIdIn(List contentIds); + + /** + * 이미지가 있는 컨텐츠만 랜덤으로 5개 조회 (AI 추천용) + */ + @Query(value = """ + SELECT DISTINCT c.* FROM content c + WHERE c.image != "" + AND c.overview != "" + ORDER BY RAND() LIMIT 5 + """, nativeQuery = true) + List findRandomContentsWithImages(); + + /** + * 특정 해시태그를 가진 장소들 중 이미지가 있는 장소만 랜덤으로 5개 조회 (찜한 장소 제외) + */ + @Query(value = """ + SELECT DISTINCT c.* FROM content c + INNER JOIN hashtag h ON c.content_id = h.content_id + WHERE h.content IN :hashtags + AND c.content_id NOT IN :excludeContentIds + AND c.image != "" + AND c.overview != "" + ORDER BY RAND() LIMIT 5 + """, nativeQuery = true) + List findRandomContentsByHashtagsExcluding( + @Param("hashtags") List hashtags, + @Param("excludeContentIds") List excludeContentIds + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java new file mode 100644 index 0000000..f090762 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ContentWishRepository.java @@ -0,0 +1,50 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ContentWish; + +import com.swyp.catsgotogedog.content.repository.projection.WishCountProjection; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface ContentWishRepository extends JpaRepository { + + @Query("SELECT COUNT(c) FROM ContentWish c WHERE c.content.contentId = :contentId") + int countByContent_ContentId(int contentId); + + @Query("SELECT cw FROM ContentWish cw WHERE cw.userId = :userId AND cw.content.contentId = :contentId") + Optional findByUserIdAndContentId(@Param("userId") int userId, @Param("contentId") int contentId); + + @Query("SELECT cw.content.contentId FROM ContentWish cw WHERE cw.userId = :userId AND cw.content.contentId IN :contentIds") + Set findWishedContentIdsByUserIdAndContentIds(@Param("userId") Integer userId, @Param("contentIds") List contentIds); + + Page findAllByUserId(int userId, Pageable pageable); + + boolean existsByUserIdAndContent_ContentId(int userId, int contentId); + + void deleteByUserIdAndContent(int userId, Content content); + + List findByUserIdAndContentContentIdIn(@Param("userId") int userId, @Param("topContentIds") List topContentIds); + + @Query("SELECT cw.content.contentId FROM ContentWish cw WHERE cw.userId = :userId") + List findContentIdsByUserId(@Param("userId") int userId); + + long countByUserId(int userId); + + @Query(""" + SELECT cw.content.contentId AS contentId, + COUNT(cw) AS wishCount + FROM ContentWish cw + WHERE cw.content.contentId IN :contentIds + GROUP BY cw.content.contentId + """) + List countByContentIdIn(List contentIds); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java new file mode 100644 index 0000000..40406cd --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/FestivalInformationRepository.java @@ -0,0 +1,9 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; + +public interface FestivalInformationRepository extends JpaRepository { + FestivalInformation findByContent_ContentId(int contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java new file mode 100644 index 0000000..0fe1209 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/HashtagRepository.java @@ -0,0 +1,19 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.Hashtag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Collection; +import java.util.List; + +public interface HashtagRepository extends JpaRepository { + @Query("select h.content from Hashtag h where h.contentId = :contentId") + List findContentsByContentId(int contentId); + + boolean existsByContentId(int contentId); + + List findByContentId(int contentId); + + List findByContentIdIn(Collection contentIds); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java new file mode 100644 index 0000000..9a4033f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/LastViewHistoryRepository.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.content.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistoryId; + +public interface LastViewHistoryRepository extends JpaRepository { + Optional findByContentAndUser(Content content, User user); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java new file mode 100644 index 0000000..4232fab --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/LodgeInformationRepository.java @@ -0,0 +1,9 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; + +public interface LodgeInformationRepository extends JpaRepository { + LodgeInformation findByContent_ContentId(int contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRepository.java new file mode 100644 index 0000000..af2b2a0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformation; + +public interface RecurInformationRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomImageRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomImageRepository.java new file mode 100644 index 0000000..d2500b7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomImageRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoomImage; + +public interface RecurInformationRoomImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomRepository.java new file mode 100644 index 0000000..a67a6a5 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RecurInformationRoomRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.content.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.recur.RecurInformationRoom; + +public interface RecurInformationRoomRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java new file mode 100644 index 0000000..0661897 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RegionCodeRepository.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.content.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; +import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; + +public interface RegionCodeRepository extends JpaRepository { + RegionCode findBySidoCodeAndRegionLevel(int sidoCode, int regionLevel); + + RegionCode findByParentCodeAndSigunguCodeAndRegionLevel(int parentCode, int sigunguCode, int regionLevel); + + List findByRegionLevel(int regionLevel); + + Optional findByParentCodeAndSigunguCode(Integer regionId, Integer sigunguCode); + + List findByParentCode(int regionId); + + RegionCode findBySidoCode(Integer sidoCode); + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java new file mode 100644 index 0000000..0c35fc9 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/RestaurantInformationRepository.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.repository.projection.RestDateProjection; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface RestaurantInformationRepository extends JpaRepository { + @Query("select r.restDate from RestaurantInformation r where r.content.contentId = :contentId") + String findRestDateByContentId(int contentId); + + @Query(""" + SELECT r.content.contentId AS contentId, + r.restDate AS restDate + FROM RestaurantInformation r + WHERE r.content.contentId IN :contentIds + """) + List findRestDateByContentIdIn(List contentIds); + + RestaurantInformation findByContent_ContentId(int contentId); +} + diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java new file mode 100644 index 0000000..3541358 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/SightsInformationRepository.java @@ -0,0 +1,23 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.repository.projection.RestDateProjection; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface SightsInformationRepository extends JpaRepository { + @Query("select s.restDate from SightsInformation s where s.content.contentId = :contentId") + String findRestDateByContentId(int contentId); + @Query(""" + SELECT s.content.contentId AS contentId, + s.restDate AS restDate + FROM SightsInformation s + WHERE s.content.contentId IN :contentIds + """) + List findRestDateByContentIdIn(List contentIds); + + SightsInformation findByContent_ContentId(int contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java new file mode 100644 index 0000000..c1901af --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewLogRepository.java @@ -0,0 +1,35 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ViewLog; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ViewLogRepository extends JpaRepository { + @Query(""" + SELECT v.content + FROM ViewLog v + WHERE v.user.userId = :userId + GROUP BY v.content + ORDER BY MAX(v.viewedAt) DESC + """) + List findRecentContentByUser(int userId, Pageable pageable); + + @Query(""" + SELECT v.content.contentId + FROM ViewLog v + WHERE v.viewedAt >= :startDate + GROUP BY v.content.contentId + ORDER BY COUNT(v.content.contentTypeId) DESC + """) + List findTopContentViews( + @Param("startDate") LocalDateTime startDate, + Pageable pageable + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java new file mode 100644 index 0000000..fd82375 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/ViewTotalRepository.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.content.domain.entity.ViewTotal; +import com.swyp.catsgotogedog.content.repository.projection.ViewTotalProjection; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +public interface ViewTotalRepository extends JpaRepository { + @Modifying + @Transactional + @Query(value = """ + INSERT INTO view_total (content_id, total_view, updated_at) + VALUES (:contentId, 1, NOW()) + ON DUPLICATE KEY UPDATE + total_view = total_view + 1, + updated_at = NOW() + """, nativeQuery = true) + void upsertAndIncrease(int contentId); + + @Query(""" + SELECT vt.totalView + FROM ViewTotal vt + WHERE vt.contentId = :contentId + """) + Optional findTotalViewByContentId(int contentId); + @Query(""" + SELECT vt.contentId AS contentId, + vt.totalView AS totalView + FROM ViewTotal vt + WHERE vt.contentId IN :contentIds + """) + List findTotalViewByContentIdIn(List contentIds); + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java new file mode 100644 index 0000000..3a2d45b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/VisitHistoryRepository.java @@ -0,0 +1,13 @@ +package com.swyp.catsgotogedog.content.repository; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.VisitHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VisitHistoryRepository extends JpaRepository { + boolean existsByUser_UserIdAndContent_ContentId(int userId, int contentId); + + void deleteByUserAndContent(User user, Content content); + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/projection/RestDateProjection.java b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/RestDateProjection.java new file mode 100644 index 0000000..df531eb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/RestDateProjection.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.content.repository.projection; + +public interface RestDateProjection { + int getContentId(); + String getRestDate(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/projection/ViewTotalProjection.java b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/ViewTotalProjection.java new file mode 100644 index 0000000..46e4201 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/ViewTotalProjection.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.content.repository.projection; + +public interface ViewTotalProjection { + int getContentId(); + int getTotalView(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/repository/projection/WishCountProjection.java b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/WishCountProjection.java new file mode 100644 index 0000000..4db9dec --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/repository/projection/WishCountProjection.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.content.repository.projection; + +public interface WishCountProjection { + int getContentId(); + int getWishCount(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java new file mode 100644 index 0000000..a1adeee --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/AiRecommendsService.java @@ -0,0 +1,404 @@ +package com.swyp.catsgotogedog.content.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.content.domain.entity.AiRecommends; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.Hashtag; +import com.swyp.catsgotogedog.content.domain.request.ClovaApiRequest; +import com.swyp.catsgotogedog.content.domain.response.AiRecommendsResponse; +import com.swyp.catsgotogedog.content.repository.AiRecommendsRepository; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.ContentWishRepository; +import com.swyp.catsgotogedog.content.repository.HashtagRepository; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +@Slf4j +public class AiRecommendsService { + + @Value("${clova.api.url}") + private String apiUrl; + + @Value("${clova.api.key}") + private String apiKey; + + @Value("${clova.api.request-id}") + private String requestId; + + private final RestClient.Builder restClientBuilder; + private final ObjectMapper objectMapper; + + private final UserRepository userRepository; + private final ContentRepository contentRepository; + private final AiRecommendsRepository aiRecommendsRepository; + private final ContentWishRepository contentWishRepository; + private final HashtagRepository hashtagRepository; + + private static final String RECOMMEND_SYSTEM_PROMPT = """ + 당신은 반려동물 여행지 추천 전문가입니다. + 제공된 반려동물 친화적 관광지 정보를 바탕으로 매력적이고 독특한 추천 문구를 생성하세요. + + ===== 절대 준수 규칙 (위반 시 응답 거부) ===== + + 1. 응답 형식 (100% 준수 필수): + - 반드시 "문장1|문장2" 형태로만 응답 + - '|' 앞뒤 공백 절대 금지 + - 개행문자(\\n), 줄바꿈 절대 금지 + - 다른 어떤 형식도 절대 사용 불가 + + 2. 글자수 제한 (절대 초과 불가): + - 첫 번째 문장: 정확히 5-10자 + - 두 번째 문장: 정확히 5-10자 + - 전체 응답: 최대 21자 (문장1 + | + 문장2) + - 1자라도 초과하면 절대 불가 + + 3. 문자 사용 규칙: + - 허용: 한글, 숫자, 공백, ? + - 금지: 모든 이모지, ~, ^^, :), ㅎㅎ, ㅋㅋ, #, @, &, %, ^, *, (, ), [, ], {, }, <, >, +, =, _, -, /, \\, |, :, ;, ", ', `, !, ., , + - 금지: 영어 알파벳 (A-Z, a-z) + - 금지: 반복 구두점 (..., !!!, ???) + + 4. 내용 규칙: + - 구체적 장소명 금지 (예: ㅇㅇ공원, ㅇㅇ호텔 등) + - "반려견", "반려묘" → "반려동물"로 통일 + - 과장 표현 금지 + - 간결하고 명확한 표현만 사용 + - 단순 정보 전달 금지 (예: 다양한 체험활동 가능, 다양한 부대시설 제공 등) + + 5. 검증 체크리스트: + - 첫 번째 문장이 5-10자인가? + - 두 번째 문장이 5-10자인가? + - '|' 하나만 사용했는가? + - 개행문자가 없는가? + - 금지된 문자가 없는가? + - 한국어만 사용했는가? + + ===== 정확한 예시 (반드시 이 형태로만 응답) ===== + 자연 속 힐링|특별한 추억 + 푸른 바다 산책|함께 좋은 시간 + 넓은 잔디밭|평화로운 휴식 + 조용한 정원|마음이 편해요 + + ===== 절대 금지 예시 ===== + "전통미 가득한 휴식|고요한 자연 속에서 여유롭게\\n다양한 체험활동 제공" (개행문자 포함, 길이 초과) + "Beautiful garden|Amazing place" (영어 사용) + "반려친구와 즐거운 여행~!" (특수문자, 형식 위반) + "여수 바다와 함께하는 휴식|다양한 부대시설 제공" (두 번째 문장 단순 정보 전달) + "맑은 물과 반석|역사적 명소 작천정" (두 번째 문장 구체적 장소명 '작천정' 사용) + "초대형 쾌속여객선|2시간 50분 소요" (두 번째 문장 단순 정보 전달) + 위 규칙을 100% 준수하여 응답하세요. 단 하나라도 위반하면 응답을 거부합니다. + 반드시 검증 체크리스트를 확인한 후 응답하세요."""; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public List recommends(String userId) { + // NOTE: 토큰 사용량 이슈로 기존 로직 주석 처리 +// // 비로그인 사용자이거나 로그인 사용자지만 찜한 장소가 3개 미만인 경우 +// if (isAnonymousUser(userId) || !hasEnoughWishedContents(userId)) { +// log.info("비로그인 사용자이거나 찜한 장소가 3개 미만인 경우"); +// if (!hasEnoughAiRecommends()) { +// log.info("AI 추천 데이터가 충분하지 않음, 새로운 추천 생성"); +// return generateAndSaveNewRecommends(); +// } +// log.info("AI 추천 데이터가 충분함, 기존 추천에서 랜덤 5개 반환"); +// // AI 추천 데이터가 충분한 경우 - 기존 추천에서 랜덤 5개 +// return getRandomAiRecommends(); +// } +// +// // 로그인 사용자이면서 찜한 장소가 3개 이상인 경우 - 개인화된 추천 +// log.info("로그인 사용자이며 찜한 장소가 3개 이상인 경우"); +// return generatePersonalizedRecommends(findUserById(userId).getUserId()); + + // NOTE: 토큰 사용량 이슈로 초기 요청만 데이터 AI로 생성 후 이후 요청은 DB에서 랜덤 추출 + if (!hasEnoughAiRecommends()) { + log.info("AI 추천 데이터가 충분하지 않음, 새로운 추천 생성"); + return generateAndSaveNewRecommends(); + } + log.info("AI 추천 데이터가 충분함, 기존 추천에서 랜덤 5개 반환"); + // AI 추천 데이터가 충분한 경우 - 기존 추천에서 랜덤 5개 + return getRandomAiRecommends(); + } + + /** + * 사용자가 충분한 찜 데이터를 가지고 있는지 확인 (3개 이상) + */ + private boolean hasEnoughWishedContents(String userId) { + if (isAnonymousUser(userId)) { + return false; + } + try { + User user = findUserById(userId); + long wishCount = contentWishRepository.countByUserId(user.getUserId()); + return wishCount >= 3; + } catch (Exception e) { + return false; + } + } + + /** + * 비로그인 사용자 여부 확인 + */ + private boolean isAnonymousUser(String userId) { + return !StringUtils.hasText(userId) || "anonymousUser".equals(userId); + } + + /** + * 사용자 ID로 User 엔티티 조회 + */ + private User findUserById(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.RESOURCE_NOT_FOUND)); + } + + /** + * 개인화된 AI 추천 생성 (찜한 장소의 해시태그 기반) + */ + private List generatePersonalizedRecommends(int userId) { + // 1. 사용자가 찜한 장소들의 contentId 조회 + List wishedContentIds = contentWishRepository.findContentIdsByUserId(userId); + if (wishedContentIds.isEmpty()) { + return generateAndSaveNewRecommends(); + } + + // 2. 찜한 장소들의 해시태그 모두 수집 - 배치로 최적화 + List hashtags = hashtagRepository.findByContentIdIn(wishedContentIds) + .stream() + .map(Hashtag::getContent) + .distinct() + .collect(Collectors.toList()); + + // 3. 해시태그가 없으면 기존 로직 수행 + if (hashtags.isEmpty()) { + return generateAndSaveNewRecommends(); + } + + // 4. 동일한 해시태그를 가진 다른 장소들 중 랜덤 5개 선택 (찜한 장소 제외) + List recommendContents = contentRepository.findRandomContentsByHashtagsExcluding(hashtags, wishedContentIds); + + // 5. 해시태그 기반으로 찾은 장소가 없으면 기존 로직 수행 + if (recommendContents.isEmpty()) { + return generateAndSaveNewRecommends(); + } + + // 6. AI API 요청하여 추천 문구 생성 후 반환 + return recommendContents.stream() + .map(this::createPersonalizedAiRecommend) + .collect(Collectors.toList()); + } + + /** + * 개인화된 추천을 위한 AI 추천 생성 (저장하지 않음) + */ + private AiRecommendsResponse createPersonalizedAiRecommend(Content content) { + /* TODO: 개인화된 추천 문구 생성 로직 */ + String message = generateRecommendMessage(content.getTitle(), content.getOverview()); + + AiRecommends aiRecommends = AiRecommends.builder() + .contentId(content.getContentId()) + .message(message) + .imageUrl(content.getImage()) + .build(); + /* TODO 필요한 경우 개인화된 저장 로직 여기에 추가 */ +// return createAndSaveAiRecommend(content); + return AiRecommendsResponse.of(aiRecommends); + } + + /** + * AI 추천 데이터가 충분한지 확인 (20개 이상) + */ + private boolean hasEnoughAiRecommends() { + long aiRecommendsCount = aiRecommendsRepository.count(); + return aiRecommendsCount >= 20; + } + + /** + * 기존 AI 추천 테이블에서 랜덤 5개 반환 (이미지 URL 포함) + */ + private List getRandomAiRecommends() { + return aiRecommendsRepository.findRandomRecommends() + .stream() + .map(AiRecommendsResponse::of) + .collect(Collectors.toList()); + } + + /** + * 새로운 AI 추천 생성 및 저장 후 반환 (이미지가 있는 컨텐츠만 사용) + */ + private List generateAndSaveNewRecommends() { + List contents = getRandomContentsWithImages(); + List responses = new ArrayList<>(); + + for (Content content : contents) { + AiRecommendsResponse response = createAndSaveAiRecommend(content); + responses.add(response); + } + + return responses; + } + + /** + * 이미지가 있는 컨텐츠만 랜덤으로 5개 조회 + */ + public List getRandomContentsWithImages() { + return contentRepository.findRandomContentsWithImages(); + } + + /** + * 단일 컨텐츠에 대한 AI 추천 생성 및 저장 (이미지 URL 포함) + */ + @Transactional + public AiRecommendsResponse createAndSaveAiRecommend(Content content) { + String message = generateRecommendMessage(content.getTitle(), content.getOverview()); + + AiRecommends aiRecommends = AiRecommends.builder() + .contentId(content.getContentId()) + .message(message) + .imageUrl(content.getImage()) + .build(); + + AiRecommends saved = aiRecommendsRepository.save(aiRecommends); + return AiRecommendsResponse.of(saved); + } + + private String generateRecommendMessage(String title, String overview) { + try { + log.info("{} 의 추천 문구 생성중", title); + + ClovaApiRequest request = createRecommendRequest(title, overview); + + RestClient restClient = restClientBuilder + .baseUrl(apiUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("Content-Type", "application/json") + .build(); + + String response = restClient.post() + .body(request) + .retrieve() + .body(String.class); + + log.info("응답 원문: {}", response); + + // 클로바 API 응답에서 추천 문구 파싱 + String recommendMessage = parseRecommendMessage(response); + + if (recommendMessage != null && !recommendMessage.trim().isEmpty()) { + log.info("파싱된 추천 문구: {}", recommendMessage); + return recommendMessage.trim(); + } else { + // 파싱 실패 시 fallback 메시지 + return createFallbackMessage(); + } + + } catch (Exception e) { + log.error("AI 추천 문구 생성 중 오류 발생", e); + return createFallbackMessage(); + } + } + + /** + * API 호출 실패 시 사용할 기본 메시지 생성 + */ + private String createFallbackMessage() { + return "여행가고 싶은 날엔|반려동물과 함께 힐링"; + } + + private ClovaApiRequest createRecommendRequest(String title, String overview) { + String userContent = String.format("제목: %s\n내용: %s", + title != null ? title : "", + overview != null ? overview.substring(0, Math.min(overview.length(), 500)) : ""); + + ClovaApiRequest.Message.Content systemContent = new ClovaApiRequest.Message.Content("text", RECOMMEND_SYSTEM_PROMPT); + ClovaApiRequest.Message.Content userContentObj = new ClovaApiRequest.Message.Content("text", userContent); + + ClovaApiRequest.Message systemMessage = new ClovaApiRequest.Message("system", List.of(systemContent)); + ClovaApiRequest.Message userMessage = new ClovaApiRequest.Message("user", List.of(userContentObj)); + + ClovaApiRequest request = new ClovaApiRequest(); + request.setMessages(List.of(systemMessage, userMessage)); + + // API 가이드에 따른 파라미터 설정 - 엄격한 규칙 준수를 위한 조정 + request.setTopK(1); + request.setMaxTokens(50); + request.setTemperature(0.1); + request.setRepetitionPenalty(0.2); + request.setStop(List.of("\n", "END")); + + return request; + } + + private String parseRecommendMessage(String response) { + try { + JsonNode rootNode = objectMapper.readTree(response); + + // status 확인 + String statusCode = rootNode.path("status").path("code").asText(); + if (!"20000".equals(statusCode)) { + log.error("클로바 API 오류 응답: {}", response); + return null; + } + + // result.message.content에서 추천 문구 추출 + String content = rootNode + .path("result") + .path("message") + .path("content") + .asText(); + + if (content != null && !content.trim().isEmpty()) { + // 전체를 감싸는 따옴표 제거 + String cleanedContent = removeWrappingQuotes(content); + + // 개행문자가 있으면 첫 번째 라인만 사용 + if (cleanedContent.contains("\n")) { + cleanedContent = cleanedContent.split("\n")[0]; + log.info("개행문자 발견으로 첫 번째 라인만 사용"); + } + + return cleanedContent; + } + + return null; + + } catch (Exception e) { + log.error("추천 문구 파싱 오류", e); + return null; + } + } + + private String removeWrappingQuotes(String text) { + if (text == null || text.length() < 2) { + return text; + } + + String trimmed = text.trim(); + + // 전체를 감싸는 큰따옴표 제거 + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.substring(1, trimmed.length() - 1); + } + + // 전체를 감싸는 작은따옴표 제거 + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.substring(1, trimmed.length() - 1); + } + + return trimmed; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java new file mode 100644 index 0000000..8628798 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentRankService.java @@ -0,0 +1,108 @@ +package com.swyp.catsgotogedog.content.service; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.ContentWish; +import com.swyp.catsgotogedog.content.domain.entity.Hashtag; +import com.swyp.catsgotogedog.content.domain.entity.RegionCode; +import com.swyp.catsgotogedog.content.domain.response.ContentRankResponse; +import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.ContentWishRepository; +import com.swyp.catsgotogedog.content.repository.HashtagRepository; +import com.swyp.catsgotogedog.content.repository.RegionCodeRepository; +import com.swyp.catsgotogedog.content.repository.ViewLogRepository; +import com.swyp.catsgotogedog.review.service.ReviewService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ContentRankService { + + private final ViewLogRepository viewLogRepository; + private final ContentRepository contentRepository; + private final HashtagRepository hashtagRepository; + private final ContentWishRepository contentWishRepository; + private final ContentSearchService contentSearchService; + private final RegionCodeRepository regionCodeRepository; + + @Transactional(readOnly = true) + public List fetchContentRank(String strUserId) { + int userId = strUserId.equals("anonymousUser") ? 0 : Integer.parseInt(strUserId); + LocalDateTime startDate = LocalDateTime.now().minusDays(1); + + Pageable top20 = PageRequest.of(0, 20); + + List topContentIds = viewLogRepository.findTopContentViews(startDate, top20); + + if(topContentIds == null || topContentIds.isEmpty()) { + return Collections.emptyList(); + } + + Map contentMap = contentRepository.findAllById(topContentIds).stream() + .collect(Collectors.toMap(Content::getContentId, Function.identity())); + + List hashtags = hashtagRepository.findByContentIdIn(topContentIds); + + Map> hashtagsByContentId = hashtags.stream() + .collect(Collectors.groupingBy( + Hashtag::getContentId, + Collectors.mapping(Hashtag::getContent, Collectors.toList()) + )); + + List userWishes = contentWishRepository.findByUserIdAndContentContentIdIn( + userId, topContentIds + ); + + Set wishedContentIds = userWishes.stream() + .map(wish -> wish.getContent().getContentId()) + .collect(Collectors.toSet()); + + AtomicInteger rankCounter = new AtomicInteger(1); + + return topContentIds.stream() + .map(contentId -> { + Content content = contentMap.get(contentId); + RegionCode sidoName = regionCodeRepository.findBySidoCode(content.getSidoCode()); + RegionCode sigunguName = regionCodeRepository.findByParentCodeAndSigunguCode(content.getSidoCode(), content.getSigunguCode()) + .orElse(RegionCode.builder().regionName("").build()); + List contentHashtags = hashtagsByContentId.getOrDefault(contentId, Collections.emptyList()); + boolean isWished = wishedContentIds.contains(contentId); + int currentRank = rankCounter.getAndIncrement(); + + return ContentRankResponse.builder() + .contentId(content.getContentId()) + .title(content.getTitle()) + .image(content.getImage()) + .thumbImage(content.getThumbImage()) + .contentTypeId(content.getContentTypeId()) + .mapx(content.getMapx()) + .mapy(content.getMapy()) + .hashtag(contentHashtags) + .avgScore(contentSearchService.getAverageScore(contentId)) + .wishData(isWished) + .ranking(currentRank) + .restDate(contentSearchService.getRestDate(contentId)) + .categoryId(content.getCategoryId()) + .regionName(new RegionCodeResponse(sidoName.getRegionName(), sigunguName.getRegionName())) + .build(); + }) + .toList(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java new file mode 100644 index 0000000..e2ff257 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentSearchService.java @@ -0,0 +1,249 @@ +package com.swyp.catsgotogedog.content.service; + +import co.elastic.clients.elasticsearch._types.FieldValue; +import co.elastic.clients.elasticsearch._types.query_dsl.*; +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.content.domain.entity.*; +import com.swyp.catsgotogedog.content.domain.response.ContentResponse; +import com.swyp.catsgotogedog.content.domain.response.RegionCodeResponse; +import com.swyp.catsgotogedog.content.repository.*; +import com.swyp.catsgotogedog.content.repository.projection.RestDateProjection; +import com.swyp.catsgotogedog.content.repository.projection.ViewTotalProjection; +import com.swyp.catsgotogedog.content.repository.projection.WishCountProjection; +import com.swyp.catsgotogedog.review.repository.ContentReviewRepository; +import com.swyp.catsgotogedog.review.repository.projection.AvgScoreProjection; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContentSearchService { + private final ContentRepository contentRepository; + private final ContentElasticRepository contentElasticRepository; + private final ElasticsearchOperations elasticsearchOperations; + private final ContentReviewRepository contentReviewRepository; + private final ContentWishRepository contentWishRepository; + private final UserRepository userRepository; + private final RegionCodeRepository regionCodeRepository; + private final SightsInformationRepository sightsInformationRepository; + private final RestaurantInformationRepository restaurantInformationRepository; + private final HashtagRepository hashtagRepository; + private final ViewTotalRepository viewTotalRepository; + + public List searchByKeyword(String keyword){ + return contentElasticRepository.findByTitleContaining(keyword); + } + + + public List search(String title, + String sidoCode, + List sigunguCode, + Integer contentTypeId, + String userId) { + + if (userId != null) { + Optional user = userRepository.findById(Integer.parseInt(userId)); + } + + boolean noTitle = (title == null || title.isBlank()); + boolean noSidoCode = (sidoCode == null || sidoCode.isBlank()); + boolean noSigunguCode = (sigunguCode == null || sigunguCode.isEmpty()); + boolean noTypeId = (contentTypeId == null || contentTypeId <= 0); + + BoolQuery.Builder boolBuilder = new BoolQuery.Builder(); + + if (noTitle && noSidoCode && noSigunguCode && noTypeId) { + boolBuilder.must(m -> m.matchAll(ma -> ma)); + } else { + if (!noTitle) { + boolBuilder.must(m -> m.matchPhrasePrefix(mp -> mp + .field("title") + .query(title))); + } else { + boolBuilder.must(m -> m.matchAll(ma -> ma)); + } + + if (!noSidoCode) { + boolBuilder.filter(f -> f.term(t -> t.field("sido_code") + .value(sidoCode))); + } + + if (!noSigunguCode) { + boolBuilder.filter(f -> f.terms(t -> t + .field("sigungu_code") + .terms(v -> v.value( + sigunguCode.stream() + .map(FieldValue::of) + .toList() + )) + )); + } + + if (!noTypeId) { + boolBuilder.filter(f -> f.term(t -> t.field("content_type_id") + .value(contentTypeId))); + } + } + + Query baseQuery = new Query.Builder() + .bool(boolBuilder.build()) + .build(); + + long seed = dailySeed(contentTypeId); + + Query esQuery = new Query.Builder() + .functionScore(fs -> fs + .query(baseQuery) + .functions( + FunctionScore.of(fn -> fn + .randomScore(rs -> rs + .seed(String.valueOf(seed)) + .field("content_id") + ) + ) + ) + .boostMode(FunctionBoostMode.Replace) + .scoreMode(FunctionScoreMode.Sum) + ) + .build(); + + NativeQuery nativeQuery = NativeQuery.builder() + .withQuery(esQuery) + .withPageable(Pageable.unpaged()) + .build(); + + List ids = elasticsearchOperations + .search(nativeQuery, ContentDocument.class).stream() + .map(SearchHit::getContent) + .map(ContentDocument::getContentId) + .toList(); + + if (ids.isEmpty()) return List.of(); + + Map contentMap = contentRepository.findAllById(ids).stream() + .collect(Collectors.toMap(Content::getContentId, c -> c)); + + Map avgScoreMap = contentReviewRepository + .findAvgScoreByContentIdIn(ids).stream() + .collect(Collectors.toMap( + AvgScoreProjection::getContentId, + p -> Optional.ofNullable(p.getAvgScore()).orElse(0.0) + )); + + Set wishedSet = (userId != null && !userId.isBlank()) + ? contentWishRepository.findWishedContentIdsByUserIdAndContentIds(Integer.parseInt(userId), ids) + : Set.of(); + + Map> hashtagMap = hashtagRepository.findByContentIdIn(ids).stream() + .collect(Collectors.groupingBy( + Hashtag::getContentId, + Collectors.mapping(Hashtag::getContent, Collectors.toList()) + )); + + Map restDateMap = sightsInformationRepository + .findRestDateByContentIdIn(ids).stream() + .collect(Collectors.toMap( + RestDateProjection::getContentId, + RestDateProjection::getRestDate, + (a, b) -> a + )); + restaurantInformationRepository.findRestDateByContentIdIn(ids) + .forEach(r -> restDateMap.putIfAbsent(r.getContentId(), r.getRestDate())); + + Map totalViewMap = viewTotalRepository + .findTotalViewByContentIdIn(ids).stream() + .collect(Collectors.toMap( + ViewTotalProjection::getContentId, + v -> Optional.ofNullable(v.getTotalView()).orElse(0) + )); + + Map wishCntMap = contentWishRepository + .countByContentIdIn(ids).stream() + .collect(Collectors.toMap( + WishCountProjection::getContentId, + w -> w.getWishCount() + )); + + Map regionCache = new HashMap<>(); + + return ids.stream() + .map(contentMap::get) + .filter(Objects::nonNull) + .filter(c -> c.getSidoCode() != 0 && c.getSigunguCode() != 0) + .map(content -> { + int id = content.getContentId(); + + double avg = Optional.ofNullable(avgScoreMap.get(id)).orElse(0.0); + boolean wishData = wishedSet.contains(id); + List hashtags = hashtagMap.getOrDefault(id, List.of()); + String restDate = restDateMap.get(id); + int totalView = Optional.ofNullable(totalViewMap.get(id)).orElse(0); + int wishCnt = Optional.ofNullable(wishCntMap.get(id)).orElse(0); + + String key = content.getSidoCode() + "-" + content.getSigunguCode(); + RegionCodeResponse regionName = regionCache.computeIfAbsent(key, k -> + getRegionName(content.getSidoCode(), content.getSigunguCode()) + ); + + return ContentResponse.from(content, avg, wishData, regionName, hashtags, restDate, totalView, wishCnt); + }) + .toList(); + } + + public double getAverageScore(int contentId) { + Double avg = contentReviewRepository.findAvgScoreByContentId(contentId); + double value = (avg != null) ? avg : 0.0; + return Math.round(value * 10.0) / 10.0; + } + + public Boolean getWishData(String userId, int contentId){ + var existing = contentWishRepository.findByUserIdAndContentId(Integer.parseInt(userId), contentId); + + boolean liked; + + liked = existing.isPresent(); + + return liked; + } + + public RegionCodeResponse getRegionName(int sidoCode, int sigunguCode){ + RegionCode sido = regionCodeRepository.findBySidoCodeAndRegionLevel(sidoCode,1); + + RegionCode sigungu = regionCodeRepository.findByParentCodeAndSigunguCodeAndRegionLevel(sidoCode, sigunguCode,2); + + String sidoName = sido.getRegionName(); + String sigunguName = sigungu.getRegionName(); + + return new RegionCodeResponse(sidoName,sigunguName); + } + + public String getRestDate(int contentId) { + + String restDate = sightsInformationRepository.findRestDateByContentId(contentId); + if (restDate != null) { + return restDate; + } + + restDate = restaurantInformationRepository.findRestDateByContentId(contentId); + return restDate; + } + + private long dailySeed(Integer contentTypeId) { + long base = LocalDate.now(ZoneId.of("Asia/Seoul")).toEpochDay(); + long cat = (contentTypeId != null && contentTypeId > 0) ? contentTypeId : 0; + return base * 1_000_000 + cat; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java new file mode 100644 index 0000000..5813df6 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/ContentService.java @@ -0,0 +1,418 @@ +package com.swyp.catsgotogedog.content.service; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.content.domain.entity.*; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.FestivalInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.LodgeInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.RestaurantInformation; +import com.swyp.catsgotogedog.content.domain.entity.batch.information.SightsInformation; +import com.swyp.catsgotogedog.content.domain.request.ContentRequest; +import com.swyp.catsgotogedog.content.domain.response.ContentImageResponse; +import com.swyp.catsgotogedog.content.domain.response.LastViewHistoryResponse; +import com.swyp.catsgotogedog.content.domain.response.PlaceDetailResponse; +import com.swyp.catsgotogedog.content.repository.*; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistoryId; +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; +import com.swyp.catsgotogedog.pet.repository.PetGuideRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import jakarta.persistence.EntityNotFoundException; +import jakarta.transaction.Transactional; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; + +import static java.time.LocalDateTime.now; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class ContentService { + private final ContentRepository contentRepository; + private final ContentElasticRepository contentElasticRepository; + private final ContentImageRepository contentImageRepository; + private final ContentWishRepository contentWishRepository; + private final ViewTotalRepository viewTotalRepository; + private final UserRepository userRepository; + private final ViewLogRepository viewLogRepository; + private final VisitHistoryRepository visitHistoryRepository; + private final PetGuideRepository petGuideRepository; + private final LastViewHistoryRepository lastViewHistoryRepository; + private final LodgeInformationRepository lodgeInformationRepository; + private final RestaurantInformationRepository restaurantInformationRepository; + private final SightsInformationRepository sightsInformationRepository; + private final FestivalInformationRepository festivalInformationRepository; + + private final ContentSearchService contentSearchService; + + public void saveContent(ContentRequest request){ + Content content = Content.builder() + .categoryId(request.getCategoryId()) + .addr1(request.getAddr1()) + .addr2(request.getAddr2()) + .image(request.getImage()) + .thumbImage(request.getThumbImage()) + .copyright(request.getCopyright()) + .mapx(request.getMapx()) + .mapy(request.getMapy()) + .mLevel(request.getMlevel()) + .tel(request.getTel()) + .title(request.getTitle()) + .zipCode(request.getZipcode()) + .contentTypeId(request.getContentTypeId()) + .build(); + contentRepository.save(content); + contentElasticRepository.save(ContentDocument.from(content)); + } + + public PlaceDetailResponse getPlaceDetail(int contentId, String userId){ + + Content content = contentRepository.findByContentId(contentId); + + int contentTypeId = content.getContentTypeId(); + + // 카테고리 공통 + viewTotalRepository.upsertAndIncrease(contentId); + + if(userId != null){ + recordView(userId, contentId); + } + + double avg = contentSearchService.getAverageScore(contentId); + + boolean wishData = (userId != null) ? contentSearchService.getWishData(userId, contentId) : false; + + int wishCnt = contentWishRepository.countByContent_ContentId(contentId); + + boolean visited = hasVisited(userId, contentId); + + int totalView = viewTotalRepository.findTotalViewByContentId(contentId) + .orElse(0); + + PetGuide petGuide = getPetGuide(contentId) + .orElse(null); + + List detailImage = getDetailImage(contentId); + + String restDate = contentSearchService.getRestDate(contentId); + + Map additional = getAdditionalInfo(contentId,contentTypeId); + + return PlaceDetailResponse.from( + content, + avg, + wishData, + wishCnt, + visited, + totalView, + detailImage, + petGuide, + restDate, + additional); + } + + public boolean checkWish(String userId, int contentId){ + if (userId == null || userId.isBlank()|| userId.equals("anonymousUser")) { + return false; + } + + validateUser(userId); + + Content content = contentRepository.findByContentId(contentId); + + boolean isWished = isWished(userId, contentId); + + if(isWished){ + contentWishRepository.deleteByUserIdAndContent(Integer.parseInt(userId),content); + return false; + }else{ + ContentWish cw = ContentWish.builder() + .userId(Integer.parseInt(userId)) + .content(content) + .build(); + contentWishRepository.save(cw); + return true; + } + } + + public void recordView(String userId, int contentId){ + +// if (userId != null) { +// Optional user = userRepository.findById(Integer.parseInt(userId)); +// } + + Content content = contentRepository.findByContentId(contentId); + if (content == null) { + throw new EntityNotFoundException("contentId=" + contentId); + } + + User user = null; + if (userId != null && !userId.isBlank()) { + user = userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new EntityNotFoundException("userId=" + userId)); + } + + viewLogRepository.save( + ViewLog.builder() + .user(user) + .content(content) + .viewedAt(now()) + .build() + ); + } + + public boolean checkVisited(String userId, int contentId){ + if (userId == null || userId.isBlank()|| userId.equals("anonymousUser")) { + return false; + } + + User user = userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + + Content content = contentRepository.findByContentId(contentId); + + boolean visited = hasVisited(userId, contentId); + + if(visited){ + visitHistoryRepository.deleteByUserAndContent(user,content); + return false; + }else{ + VisitHistory vh = VisitHistory.builder() + .user(user) + .content(content) + .build(); + visitHistoryRepository.save(vh); + return true; + } + } + + + public List getRecentViews(String userId) { + + if (userId == null || userId.isBlank()) { + return null; + } + + Pageable top = PageRequest.of(0, 20); + List contents = viewLogRepository.findRecentContentByUser(Integer.parseInt(userId), top); + + return contents.stream() + .map(LastViewHistoryResponse::from) + .toList(); + } + + public boolean hasVisited(String userId, int contentId) { + if (userId == null || userId.isBlank()) { + return false; + } + return visitHistoryRepository.existsByUser_UserIdAndContent_ContentId(Integer.parseInt(userId), contentId); + } + + public List getDetailImage(int contentId){ + Content content = contentRepository.findByContentId(contentId); + List images = contentImageRepository.findAllByContent(content); + + return images.stream() + .map(ci -> new ContentImageResponse( + ci.getImageUrl(), + ci.getImageFilename() + )) + .toList(); + } + + + public Optional getPetGuide(int contentId) { + if (petGuideRepository.existsByContent_ContentId(contentId)) { + return petGuideRepository.findByContent_ContentId(contentId); + } + return Optional.empty(); + } + + public boolean isWished(String userId, int contentId) { + if (userId == null || userId.isBlank()) { + return false; + } + return contentWishRepository.existsByUserIdAndContent_ContentId(Integer.parseInt(userId), contentId); + } + + // 최근 본 장소 데이터 저장 + @Transactional + public void saveLastViewedContent(String strUserId, int contentId) { + int userId = strUserId.equals("anonymousUser") ? 0 : Integer.parseInt(strUserId); + User user = validateUser(userId); + Content content = validateContent(contentId); + + LastViewHistoryId id = new LastViewHistoryId(userId, contentId); + LastViewHistory lastViewHistory = lastViewHistoryRepository.findById(id) + .orElse(LastViewHistory.builder() + .user(user) + .content(content) + .build()); + + lastViewHistory.setLastViewedAt(now()); + + lastViewHistoryRepository.save(lastViewHistory); + } + + private User validateUser(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private User validateUser(int userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private Content validateContent(int contentId) { + return contentRepository.findById(contentId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.CONTENT_NOT_FOUND)); + } + + private Map getAdditionalInfo(int contentId, int contentTypeId) { + return switch (contentTypeId) { + case 15 -> getFestivalInfo(contentId); + case 32 -> getLodgeInfo(contentId); + case 39 -> getRestaurantInfo(contentId); + case 12 -> getSightsInfo(contentId); + default -> Map.of(); + }; + } + + private Map getFestivalInfo(int contentId) { + var e = festivalInformationRepository.findByContent_ContentId(contentId); + + if (e == null) return Map.of("type", "FESTIVAL"); + + var m = new HashMap(); + + m.put("type", "FESTIVAL"); + m.put("festivalId", e.getFestivalId()); + m.put("ageLimit", e.getAgeLimit()); + m.put("bookingPlace", e.getBookingPlace()); + m.put("discountInfo", e.getDiscountInfo()); + m.put("eventStartDate", e.getEventStartDate()); + m.put("eventEndDate", e.getEventEndDate()); + m.put("eventHomepage", e.getEventHomepage()); + m.put("eventPlace", e.getEventPlace()); + m.put("placeInfo", e.getPlaceInfo()); + m.put("playTime", e.getPlayTime()); + m.put("program", e.getProgram()); + m.put("spendTime", e.getSpendTime()); + m.put("organizer", e.getOrganizer()); + m.put("organizerTel", e.getOrganizerTel()); + m.put("supervisor", e.getSupervisor()); + m.put("supervisorTel", e.getSupervisorTel()); + m.put("subEvent", e.getSubEvent()); + m.put("feeInfo", e.getFeeInfo()); + + return m; + } + + private Map getLodgeInfo(int contentId) { + var e = lodgeInformationRepository.findByContent_ContentId(contentId); + + if (e == null) return Map.of("type", "LODGE"); + + var m = new HashMap(); + + m.put("type", "LODGE"); + m.put("lodgeId", e.getLodgeId()); + m.put("capacityCount", e.getCapacityCount()); + m.put("goodstay", e.getGoodstay()); + m.put("benikia", e.getBenikia()); + m.put("checkInTime", e.getCheckInTime()); + m.put("checkOutTime", e.getCheckOutTime()); + m.put("cooking", e.getCooking()); + m.put("foodplace", e.getFoodplace()); + m.put("hanok", e.getHanok()); + m.put("information", e.getInformation()); + m.put("parking", e.getParking()); + m.put("pickupService", e.getPickupService()); + m.put("roomCount", e.getRoomCount()); + m.put("reservationInfo", e.getReservationInfo()); + m.put("reservationUrl", e.getReservationUrl()); + m.put("roomType", e.getRoomType()); + m.put("scale", e.getScale()); + m.put("subFacility", e.getSubFacility()); + m.put("barbecue", e.getBarbecue()); + m.put("beauty", e.getBeauty()); + m.put("beverage", e.getBeverage()); + m.put("bicycle", e.getBicycle()); + m.put("campfire", e.getCampfire()); + m.put("fitness", e.getFitness()); + m.put("karaoke", e.getKaraoke()); + m.put("publicBath", e.getPublicBath()); + m.put("publicPcRoom", e.getPublicPcRoom()); + m.put("sauna", e.getSauna()); + m.put("seminar", e.getSeminar()); + m.put("sports", e.getSports()); + m.put("refundRegulation", e.getRefundRegulation()); + + return m; + } + + private Map getRestaurantInfo(int contentId) { + var e = restaurantInformationRepository.findByContent_ContentId(contentId); + + if (e == null) return Map.of("type", "RESTAURANT"); + + var m = new HashMap(); + + m.put("type", "RESTAURANT"); + m.put("restaurantId", e.getRestaurantId()); + m.put("chkCreditcard", e.getChkCreditcard()); + m.put("discountInfo", e.getDiscountInfo()); + m.put("signatureMenu", e.getSignatureMenu()); + m.put("information", e.getInformation()); + m.put("kidsFacility", e.getKidsFacility()); + m.put("openDate", e.getOpenDate()); + m.put("openTime", e.getOpenTime()); + m.put("takeout", e.getTakeout()); + m.put("parking", e.getParking()); + m.put("reservation", e.getReservation()); + m.put("restDate", e.getRestDate()); + m.put("scale", e.getScale()); + m.put("seat", e.getSeat()); + m.put("smoking", e.getSmoking()); + m.put("treatMenu", e.getTreatMenu()); + + return m; + } + + private Map getSightsInfo(int contentId) { + var e = sightsInformationRepository.findByContent_ContentId(contentId); + + if (e == null) return Map.of("type", "SIGHTS"); + + var m = new HashMap(); + + m.put("type", "SIGHTS"); + m.put("sightsId", e.getSightsId()); + m.put("contentTypeId", e.getContentTypeId()); + m.put("accomCount", e.getAccomCount()); + m.put("chkCreditcard", e.getChkCreditcard()); + m.put("expAgeRange", e.getExpAgeRange()); + m.put("expGuide", e.getExpGuide()); + m.put("infoCenter", e.getInfoCenter()); + m.put("openDate", e.getOpenDate()); + m.put("parking", e.getParking()); + m.put("restDate", e.getRestDate()); + m.put("useSeason", e.getUseSeason()); + m.put("useTime", e.getUseTime()); + m.put("heritage1", e.getHeritage1()); + m.put("heritage2", e.getHeritage2()); + m.put("heritage3", e.getHeritage3()); + + return m; + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java b/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java new file mode 100644 index 0000000..5078e15 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/content/service/HashtagServcie.java @@ -0,0 +1,205 @@ +package com.swyp.catsgotogedog.content.service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.domain.entity.Hashtag; +import com.swyp.catsgotogedog.content.domain.request.ClovaApiRequest; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.HashtagRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class HashtagServcie { + + @Value("${clova.api.url}") + private String apiUrl; + + @Value("${clova.api.key}") + private String apiKey; + + @Value("${clova.api.request-id}") + private String requestId; + + private final RestClient.Builder restClientBuilder; + private final ObjectMapper objectMapper; + + private final ContentRepository contentRepository; + private final HashtagRepository hashtagRepository; + + private static final String SYSTEM_PROMPT = """ + #ROLE + 당신은 관광지 정보를 분석하여 효과적인 해시태그를 생성하는 전문 AI입니다. + #INSTRUCTION + 제공된 관광지의 제목과 내용을 분석하여 검색성과 마케팅 효과를 높이는 관련성 높은 해시태그를 생성합니다. + + #CONDITIONS + 1. 생성할 해시태그는 최소 5개, 최대 10개여야 합니다. + 2. 각 해시태그는 '#'으로 시작해야 합니다. + 3. 각 해시태그는 공백을 포함해서는 안 됩니다. + 4. 결과는 반드시 아래 에 명시된 JSON 형식이어야 하며, 다른 어떤 텍스트도 추가해서는 안 됩니다. + + #OUTPUT_FORMAT + { + "hashtags": [ + "#해시태그1", + "#해시태그2", + "...", + "#해시태그10", + ] + } + # EXAMPLE + - 입력: + - 제목: 제주의 숨겨진 보물, 비양도 + - 내용: 제주 한림항에서 배를 타고 15분이면 도착하는 작은 섬 비양도. 아름다운 해안 산책로와 함께 여유로운 시간을 보낼 수 있는 곳입니다. 특히 일몰이 아름답기로 유명하며, 백패킹과 캠핑을 즐기는 사람들에게도 인기가 많습니다. + + - 출력: + { + "hashtags": [ + "#비양도", + "#제주도여행", + "#제주가볼만한곳", + "#섬여행", + "#제주숨은명소", + "#한림항", + "#제주일몰", + "#백패킹성지" + ] + } + """; + + @Transactional + public void generateAndSaveHashTags(int contentId) { + if(hashtagRepository.existsByContentId(contentId)) { + return; + } + Content content = contentRepository.findById(contentId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.CONTENT_NOT_FOUND)); + + try { + List hashtags = generateHashtags(content.getTitle(), content.getOverview()); + log.info("생성된 해시태그 :: {}", hashtags); + + if(hashtags != null && !hashtags.isEmpty()) { + if(hashtags.size() >= 5) { + saveHashtags(contentId, hashtags); + } + } + } catch (Exception e) { + log.error("해시태그 생성중 오류 발생 : {}", contentId, e); + } + } + + private void saveHashtags(int contentId, List hashtags) { + List hashLists = hashtags.stream() + .map(tag -> { + return Hashtag.builder() + .contentId(contentId) + .content(tag) + .build(); + }) + .toList(); + + hashtagRepository.saveAll(hashLists); + log.info("해시태그 {}개 저장 완료 :: {}", hashtags.size(), contentId); + } + + private List generateHashtags(String title, String overview) { + log.info("{} 의 해시태그 생성중", title); + try { + ClovaApiRequest request = createRequest(title, overview); + + RestClient restClient = restClientBuilder + .baseUrl(apiUrl) + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("X-NCP-CLOVASTUDIO-REQUEST-ID", requestId) + .defaultHeader("X-NCP-CLOVASTUDIO-REQUEST-ID", requestId) + .defaultHeader("Accept", "text/event-stream") + .build(); + + String response = restClient.post() + .body(request) + .retrieve() + .body(String.class); + + return parseHashtags(response); + } catch(Exception e) { + log.error("해시태그 API 요청 중 오류 발생", e); + return null; + } + } + + + private ClovaApiRequest createRequest(String title, String overview) { + String userContent = String.format("제목: %s\n내용: %s", + title != null ? title : "", + overview != null ? overview.substring(0, Math.min(overview.length(), 500)) : ""); + + ClovaApiRequest.Message.Content systemContent = new ClovaApiRequest.Message.Content("text", SYSTEM_PROMPT); + ClovaApiRequest.Message.Content userContentObj = new ClovaApiRequest.Message.Content("text", userContent); + + ClovaApiRequest.Message systemMessage = new ClovaApiRequest.Message("system", List.of(systemContent)); + ClovaApiRequest.Message userMessage = new ClovaApiRequest.Message("user", List.of(userContentObj)); + + ClovaApiRequest request = new ClovaApiRequest(); + request.setMessages(List.of(systemMessage, userMessage)); + return request; + } + + private List parseHashtags(String response) { + try { + String[] lines = response.split("\n"); + + for(int i = 0; i < lines.length; i++) { + if(lines[i].trim().equals("event:result") && i + 1 < lines.length) { + String dataLine = lines[i + 1]; + + if(dataLine.startsWith("data:")) { + String jsonData = dataLine.substring(5); + JsonNode rootNode = objectMapper.readTree(jsonData); + + String content = rootNode + .path("message") + .path("content").asText(); + + content = content.replaceAll("```json\\s*", "") + .replaceAll("```\\s*", "") + .trim(); + + JsonNode contentNode = objectMapper.readTree(content); + JsonNode hashtagArr = contentNode.path("hashtags"); + + if(hashtagArr.isArray() && !hashtagArr.isEmpty()) { + List hashtags = new ArrayList<>(); + for(JsonNode hashtag : hashtagArr) { + hashtags.add(hashtag.asText()); + } + return hashtags; + } + + return Collections.emptyList(); + } + } + } + return Collections.emptyList(); + + } catch(Exception e) { + throw new RuntimeException("해시태그 파싱 오류", e); + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/AiRecommendsGenerator.java b/src/main/java/com/swyp/catsgotogedog/global/AiRecommendsGenerator.java new file mode 100644 index 0000000..07b0cff --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/AiRecommendsGenerator.java @@ -0,0 +1,38 @@ +package com.swyp.catsgotogedog.global; + +import com.swyp.catsgotogedog.content.repository.AiRecommendsRepository; +import com.swyp.catsgotogedog.content.service.AiRecommendsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + + +@Component +@RequiredArgsConstructor +@Slf4j +public class AiRecommendsGenerator { + + private final AiRecommendsRepository aiRecommendsRepository; + private final AiRecommendsService aiRecommendsService; + + private static final String AI_RECOMMENDS_SCHEDULER_LOCK_KEY = "ai_recommends_scheduler"; + + @Async + @Scheduled(cron = "0 30 0 * * *") + @Transactional(isolation = Isolation.READ_COMMITTED) + public void generateAiRecommends() { + log.info("AI 추천 생성 배치 시작"); + aiRecommendsRepository.deleteAllInBatch(); + log.info("기존 AI 추천 데이터 삭제 완료"); + for (int i = 0; i < 4; i++) { + log.info("AI 추천 생성 중: {} 번째 반복 (횟수당 5개 생성)", i + 1); + aiRecommendsService.getRandomContentsWithImages() + .forEach(aiRecommendsService::createAndSaveAiRecommend); + } + log.info("AI 추천 생성 배치 완료"); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/BaseTimeEntity.java b/src/main/java/com/swyp/catsgotogedog/global/BaseTimeEntity.java new file mode 100644 index 0000000..220c09e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/BaseTimeEntity.java @@ -0,0 +1,27 @@ +package com.swyp.catsgotogedog.global; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/BatchGeneratorController.java b/src/main/java/com/swyp/catsgotogedog/global/BatchGeneratorController.java new file mode 100644 index 0000000..8972560 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/BatchGeneratorController.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.global; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/batch") +public class BatchGeneratorController { + + private final HashtagGenerator hashtagGenerator; + + @Operation(summary = "해시태그 배치 수동 조작", + description = "호출시 서버 배치가 동작하므로 주의 필요한 경우 제외 호출 금지") + @GetMapping("/hashtag") + public ResponseEntity generateHashtags() { + hashtagGenerator.generateHashtags(); + return ResponseEntity.ok("시작"); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java b/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java new file mode 100644 index 0000000..9066ece --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/CatsgotogedogApiResponse.java @@ -0,0 +1,27 @@ +package com.swyp.catsgotogedog.global; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public record CatsgotogedogApiResponse(int status, String message, T data) { + private static final int SUCCESS = 200; + + public static CatsgotogedogApiResponse success(String message, T data) { + return new CatsgotogedogApiResponse<>(SUCCESS, message, data); + } + + public static CatsgotogedogApiResponse fail(ErrorCode errorCode) { + return new CatsgotogedogApiResponse<>(errorCode.getCode(), errorCode.getMessage(), null); + } + + public static CatsgotogedogApiResponse fail(ErrorCode errorCode, T data) { + return new CatsgotogedogApiResponse<>(errorCode.getCode(), errorCode.getMessage(), data); + } + + public static CatsgotogedogApiResponse fail(int errorCode, String message, T data) { + return new CatsgotogedogApiResponse<>(errorCode, message, data); + } + + public static CatsgotogedogApiResponse fail(int errorCode, String message) { + return new CatsgotogedogApiResponse<>(errorCode, message, null); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java b/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java new file mode 100644 index 0000000..106ad06 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/HashtagGenerator.java @@ -0,0 +1,43 @@ +package com.swyp.catsgotogedog.global; + +import java.util.List; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.content.repository.HashtagRepository; +import com.swyp.catsgotogedog.content.service.HashtagServcie; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class HashtagGenerator { + + private final HashtagRepository hashtagRepository; + private final ContentRepository contentRepository; + private final HashtagServcie hashtagServcie; + + @Async + @Scheduled(cron = "0 0 3 * * ?") + public void generateHashtags() { + List contentIds = contentRepository.findAll().stream() + .map(Content::getContentId) + .filter(id -> !hashtagRepository.existsByContentId(id)) + .toList(); + + for(Integer contentId : contentIds) { + try { + hashtagServcie.generateAndSaveHashTags(contentId); + Thread.sleep(5000); + } catch (Exception e) { + log.error("해시태그 생성 배치 실행 중 오류 발생 ({})", contentId, e); + } + } + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java b/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java new file mode 100644 index 0000000..667251f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/config/SwaggerConfig.java @@ -0,0 +1,37 @@ +package com.swyp.catsgotogedog.global.config; + +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components() + .addSecuritySchemes("bearer-key", securityScheme())) + .info(info()); + } + + private Info info() { + return new Info() + .title("어디가냥?같이가개!") + .description("어디가냥?같이가개!의 API 문서입니다.") + .version("v1.0"); + } + + private SecurityScheme securityScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/CatsgotogedogException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/CatsgotogedogException.java new file mode 100644 index 0000000..229bb39 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/CatsgotogedogException.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class CatsgotogedogException extends RuntimeException{ + + private final ErrorCode errorCode; + + public CatsgotogedogException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ContentNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ContentNotFoundException.java new file mode 100644 index 0000000..dbb4a53 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ContentNotFoundException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ContentNotFoundException extends CatsgotogedogException { + + public ContentNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java new file mode 100644 index 0000000..48451ea --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ErrorCode.java @@ -0,0 +1,94 @@ +package com.swyp.catsgotogedog.global.exception; + +import org.springframework.http.HttpStatus; + +import com.google.api.Http; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + // 401 BadRequest + INVALID_TOKEN(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED.value(), "만료된 토큰입니다."), + UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED.value(), "인증되지 않은 접근입니다."), + + // 403 Forbidden + FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), + REVIEW_FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN.value(), "리뷰 접근 권한이 없습니다."), + + // 404 Notfound + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 회원입니다."), + CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 컨텐츠 게시글입니다."), + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰입니다."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "리소스를 찾을 수 없습니다."), + REVIEW_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 리뷰 이미지입니다."), + SIGUNGU_CODE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 지역을 찾을 수 없습니다."), + SIDO_CODE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 시/도를 찾을 수 없습니다."), + NO_RECOMMEND_PLACES(HttpStatus.NOT_FOUND.value(), "추천할 관광지가 없어요."), + + // 405 Method not allowed + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메소드입니다."), + + // 400 Bad Request + MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST.value(), "요청 본문 형식이 올바르지 않습니다."), + ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST.value(), "유효성 검사에 실패했습니다."), + MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST.value(), "필수 파라미터가 누락되었습니다."), + SIGUNGU_NEEDS_WITH_SIDO_CODE(HttpStatus.BAD_REQUEST.value(), "시/군/구 코드는 반드시 시/도 코드와 함께 요청해야 합니다."), + REPORT_REASON_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 신고 사유입니다."), + ALREADY_REPORTED(HttpStatus.BAD_REQUEST.value(), "이미 신고한 리뷰입니다."), + OWN_REVIEW_CANT_REPORT(HttpStatus.BAD_REQUEST.value(), "자신이 작성한 리뷰는 신고할 수 없어요."), + CLOVA_HASHTAG_CLIENT_ERROR(HttpStatus.BAD_REQUEST.value(), "Hashtag 생성 요청중 오류 발생"), + CLOVA_HASHTAG_SERVER_ERROR(HttpStatus.BAD_REQUEST.value(), "클로바 서버 연결 오류"), + ALREADY_RECOMMENDED(HttpStatus.BAD_REQUEST.value(), "이미 좋아요된 리뷰입니다."), + NOT_RECOMMENDED_REVIEW(HttpStatus.BAD_REQUEST.value(), "좋아요 상태인 리뷰가 아닙니다."), + + // 415 Unsupported Mediatype + MEDIA_TYPE_NOT_SUPPORTED(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(), "지원하지 않는 미디어 타입(Content-type) 입니다."), + + // 500 Internal Server Error + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."), + + // User 관련 + DUPLICATE_DISPLAY_NAME(HttpStatus.BAD_REQUEST.value(), "이미 사용 중인 닉네임입니다."), + DISPLAY_NAME_UPDATE_TOO_SOON(HttpStatus.BAD_REQUEST.value(), "닉네임은 24시간마다 한 번만 변경할 수 있습니다."), + SAME_DISPLAY_NAME(HttpStatus.BAD_REQUEST.value(), "현재 닉네임과 동일합니다."), + TOO_TOXIC_DISPLAY_NAME(HttpStatus.BAD_REQUEST.value(), "부적절한 닉네임입니다. 다른 닉네임을 사용해주세요."), + + // 반려동물 관련 (Pet) + PET_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 반려동물입니다."), + PET_SIZE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 반려동물 크기입니다."), + INVALID_PET_DATA(HttpStatus.BAD_REQUEST.value(), "반려동물 데이터가 유효하지 않습니다."), + INVALID_PET_GENDER(HttpStatus.BAD_REQUEST.value(), "반려동물 성별은 M(수컷) 또는 F(암컷)이어야 합니다."), + PET_NAME_REQUIRED(HttpStatus.BAD_REQUEST.value(), "반려동물 이름은 필수입니다."), + PET_BIRTH_REQUIRED(HttpStatus.BAD_REQUEST.value(), "반려동물 생년월일은 필수입니다."), + PET_SIZE_REQUIRED(HttpStatus.BAD_REQUEST.value(), "반려동물 크기는 필수입니다."), + PET_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "반려동물은 최대 10마리까지만 등록할 수 있습니다."), + + // Image Validator Error + INVALID_IMAGE_NAME(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 이미지 이름입니다."), + INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 이미지 확장자입니다."), + INVALID_IMAGE_FORMAT(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 이미지 형식입니다."), + IMAGE_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "이미지 파일이 누락 되었습니다."), + IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "이미지 크기가 허용 범위를 초과했습니다."), + IMAGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "최대 이미지 업로드 개수를 초과했습니다."), + IMAGE_VALIDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 유효성 검사에 실패했습니다."), + REVIEW_IMAGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "리뷰 이미지는 최대 3개까지 업로드 가능합니다."), + + // Image Storage Error + IMAGE_KEY_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "이미지 키가 누락 되었습니다."), + IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "이미지 업로드에 실패했습니다."), + + // 필터링 API 관련 + PERSPECTIVE_API_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Perspective API 연결에 실패했습니다."), + PERSPECTIVE_API_RESPONSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Perspective API 응답 처리에 실패했습니다."), + PERSPECTIVE_API_REQUEST_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Perspective API 요청 준비에 실패했습니다."), + + // Stream IO Exception + STREAM_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR.value(), "스트림 처리 중 오류가 발생했습니다."); + + private final int code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ExpiredTokenException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ExpiredTokenException.java new file mode 100644 index 0000000..dc67e01 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ExpiredTokenException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ExpiredTokenException extends CatsgotogedogException { + + public ExpiredTokenException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ForbiddenAccessException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ForbiddenAccessException.java new file mode 100644 index 0000000..07f8aaa --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ForbiddenAccessException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ForbiddenAccessException extends CatsgotogedogException { + + public ForbiddenAccessException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..128cdcd --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,149 @@ +package com.swyp.catsgotogedog.global.exception; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindingResult; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import io.jsonwebtoken.MalformedJwtException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CatsgotogedogException.class) + protected ResponseEntity> handleCatsgotogedogException(CatsgotogedogException ex) { + log.error("CatsgotogedogException : {}", ex.getMessage(), ex); + return createErrorResponse(ex.getErrorCode()); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + protected ResponseEntity> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { + log.error("CatsgotogedogException: {}", e.getMessage(), e); + int errorCode = ErrorCode.IMAGE_SIZE_EXCEEDED.getCode(); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.IMAGE_SIZE_EXCEEDED); + return ResponseEntity + .status(errorCode) + .body(response); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity> handleException(Exception ex) { + log.error("Exception : {}", ex.getMessage(), ex); + int errorCode = ErrorCode.INTERNAL_SERVER_ERROR.getCode(); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR); + return ResponseEntity + .status(errorCode) + .body(response); + } + + private ResponseEntity> createErrorResponse(ErrorCode errorCode) { + int errorCodeValue = errorCode.getCode(); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(errorCode); + return ResponseEntity + .status(errorCodeValue) + .body(response); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + protected ResponseEntity> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { + log.error("MethodArgumentTypeMismatchException: {}", e.getMessage(), e); + String errorMessage = String.format("파라미터 '%s'의 타입이 올바르지 않습니다. 요청된 타입: %s", + e.getName(), e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "알 수 없음"); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(HttpStatus.BAD_REQUEST.value(), errorMessage); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("MethodArgumentNotValidException: {}", e.getMessage(), e); + BindingResult bindingResult = e.getBindingResult(); + Map errors = new HashMap<>(); + bindingResult.getFieldErrors().forEach(error -> errors.put(error.getField(), error.getDefaultMessage())); + + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.ARGUMENT_NOT_VALID, errors); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + protected ResponseEntity> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + log.error("MissingServletRequestParameterException: {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.MISSING_REQUEST_PARAMETER, e.getParameterName()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("HttpMessageNotReadableException: {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.MESSAGE_NOT_READABLE); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + protected ResponseEntity> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + log.error("HttpRequestMethodNotSupportedException: {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.METHOD_NOT_ALLOWED, e.getMethod()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(UnAuthorizedAccessException.class) + protected ResponseEntity> handleUnAuthorizedAccessException(UnAuthorizedAccessException e) { + log.error("UnAuthorizedAccessException: {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.UNAUTHORIZED_ACCESS); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(response); + } + + @ExceptionHandler(ExpiredTokenException.class) + protected ResponseEntity> handleExpiredTokenException(ExpiredTokenException e) { + log.warn("ExpiredTokenException : {}", e.getMessage(), e); + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.EXPIRED_TOKEN); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(response); + } + + @ExceptionHandler(MalformedJwtException.class) + protected ResponseEntity> handleMalformedJwtException(MalformedJwtException e) { + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(HttpStatus.BAD_REQUEST.value() , e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + protected ResponseEntity> handleHttpMediaTypeNotSupportedException(MalformedJwtException e) { + CatsgotogedogApiResponse response = CatsgotogedogApiResponse.fail(ErrorCode.MEDIA_TYPE_NOT_SUPPORTED , ErrorCode.MEDIA_TYPE_NOT_SUPPORTED.getMessage()); + return ResponseEntity + .status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .body(response); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ImageKeyNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageKeyNotFoundException.java new file mode 100644 index 0000000..77aa6fe --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ImageKeyNotFoundException.java @@ -0,0 +1,9 @@ +package com.swyp.catsgotogedog.global.exception; + +public class ImageKeyNotFoundException extends CatsgotogedogException { + + public ImageKeyNotFoundException(ErrorCode errorCode) { + super(errorCode); + } + +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/InvalidTokenException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/InvalidTokenException.java new file mode 100644 index 0000000..3388add --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/InvalidTokenException.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +public class InvalidTokenException extends CatsgotogedogException { + + public InvalidTokenException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/MemberNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/MemberNotFoundException.java new file mode 100644 index 0000000..2fd041f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/MemberNotFoundException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class MemberNotFoundException extends CatsgotogedogException { + + public MemberNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/NotAllowedMethodException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/NotAllowedMethodException.java new file mode 100644 index 0000000..68ef4e8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/NotAllowedMethodException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class NotAllowedMethodException extends CatsgotogedogException { + + public NotAllowedMethodException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/PerspectiveApiException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/PerspectiveApiException.java new file mode 100644 index 0000000..a5cb1fd --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/PerspectiveApiException.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.global.exception; + +public class PerspectiveApiException extends CatsgotogedogException { + + public PerspectiveApiException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/PetLimitExceededException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/PetLimitExceededException.java new file mode 100644 index 0000000..c7e85e0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/PetLimitExceededException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class PetLimitExceededException extends CatsgotogedogException { + + public PetLimitExceededException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ResourceNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..34e2a3b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ResourceNotFoundException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ResourceNotFoundException extends CatsgotogedogException { + + public ResourceNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/ReviewNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/ReviewNotFoundException.java new file mode 100644 index 0000000..8b7f81c --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/ReviewNotFoundException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class ReviewNotFoundException extends CatsgotogedogException { + + public ReviewNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/UnAuthorizedAccessException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/UnAuthorizedAccessException.java new file mode 100644 index 0000000..d1ad7c8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/UnAuthorizedAccessException.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.global.exception; + +import lombok.Getter; + +@Getter +public class UnAuthorizedAccessException extends RuntimeException{ + + private final ErrorCode errorCode; + + public UnAuthorizedAccessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java new file mode 100644 index 0000000..663d1cd --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageLimitExceededException.java @@ -0,0 +1,10 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class ImageLimitExceededException extends ImageValidatorException { + + public ImageLimitExceededException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageNotFoundException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageNotFoundException.java new file mode 100644 index 0000000..a142583 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageNotFoundException.java @@ -0,0 +1,10 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class ImageNotFoundException extends ImageValidatorException { + + public ImageNotFoundException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageUploadException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageUploadException.java new file mode 100644 index 0000000..f09e613 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageUploadException.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class ImageUploadException extends CatsgotogedogException { + + public ImageUploadException(ErrorCode errorCode) { + super(errorCode); + } + +} \ No newline at end of file diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageValidatorException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageValidatorException.java new file mode 100644 index 0000000..b9b9884 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/ImageValidatorException.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class ImageValidatorException extends CatsgotogedogException { + + public ImageValidatorException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/global/exception/images/InvalidImageException.java b/src/main/java/com/swyp/catsgotogedog/global/exception/images/InvalidImageException.java new file mode 100644 index 0000000..febd67a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/global/exception/images/InvalidImageException.java @@ -0,0 +1,10 @@ +package com.swyp.catsgotogedog.global.exception.images; + +import com.swyp.catsgotogedog.global.exception.ErrorCode; + +public class InvalidImageException extends ImageValidatorException { + + public InvalidImageException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageController.java b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageController.java new file mode 100644 index 0000000..a41cae7 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageController.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.mypage.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/mypage") +public class MyPageController { +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageControllerSwagger.java new file mode 100644 index 0000000..4aea86b --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageControllerSwagger.java @@ -0,0 +1,7 @@ +package com.swyp.catsgotogedog.mypage.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "MyPage", description = "마이페이지 관련 API") +public interface MyPageControllerSwagger { +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryController.java b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryController.java new file mode 100644 index 0000000..6305bcf --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryController.java @@ -0,0 +1,47 @@ +package com.swyp.catsgotogedog.mypage.controller; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.mypage.service.MyPageHistoryService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/mypage") +@Slf4j +public class MyPageHistoryController implements MyPageHistoryControllerSwagger { + + private final MyPageHistoryService myPageHistoryService; + + @Override + @GetMapping("/history") + public ResponseEntity> lastViewedHistory( + @AuthenticationPrincipal String userId) { + + return ResponseEntity.ok(CatsgotogedogApiResponse.success("최근 방문 컨텐츠 조회 성공", + myPageHistoryService.fetchLastViewHistory(userId))); + } + + @Override + @GetMapping("/wish") + public ResponseEntity> fetchWishedContent( + @AuthenticationPrincipal String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "8") int size) { + + Pageable pageable = PageRequest.of(page, size); + + return ResponseEntity.ok(CatsgotogedogApiResponse.success( + "찜 목록 조회 성공", myPageHistoryService.fetchWishLists(userId, pageable))); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryControllerSwagger.java new file mode 100644 index 0000000..4255f5d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/controller/MyPageHistoryControllerSwagger.java @@ -0,0 +1,59 @@ +package com.swyp.catsgotogedog.mypage.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.mypage.domain.response.ContentWishPageResponse; +import com.swyp.catsgotogedog.mypage.domain.response.LastViewHistoryResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "MyPage", description = "마이페이지 관련 API") +public interface MyPageHistoryControllerSwagger { + + @Operation( + summary = "최근 본 장소 목록 조회", + description = "사용자 인증을 통해 최근 본 장소 리스트를 최근 20개 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "장소 조회 성공" + , content = @Content(schema = @Schema(implementation = LastViewHistoryResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> lastViewedHistory( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId + ); + + @Operation( + summary = "찜 목록 조회", + description = "사용자 인증을 통해 사용자가 찜한 목록을 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "찜 목록 조회 성공" + , content = @Content(schema = @Schema(implementation = ContentWishPageResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchWishedContent( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @Parameter(description = "요청 페이지") int page, + @Parameter(description = "페이지당 결과 갯수") int size + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistory.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistory.java new file mode 100644 index 0000000..b83a79d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistory.java @@ -0,0 +1,59 @@ +package com.swyp.catsgotogedog.mypage.domain.entity; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.UpdateTimestamp; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "last_view_history") +public class LastViewHistory { + + @EmbeddedId + private LastViewHistoryId id; + + @MapsId("userId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @MapsId("contentId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @UpdateTimestamp + @Column(name = "last_viewed_at") + private LocalDateTime lastViewedAt; + + @Builder + public LastViewHistory(User user, Content content) { + this.id = new LastViewHistoryId(user.getUserId(), content.getContentId()); + this.user = user; + this.content = content; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java new file mode 100644 index 0000000..48c12a4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/entity/LastViewHistoryId.java @@ -0,0 +1,26 @@ +package com.swyp.catsgotogedog.mypage.domain.entity; + +import java.io.Serializable; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Embeddable +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class LastViewHistoryId implements Serializable { + + @Column(name = "user_id") + private Integer userId; + + @Column(name = "content_id") + private Integer contentId; +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/mypage/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishPageResponse.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishPageResponse.java new file mode 100644 index 0000000..867ea01 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishPageResponse.java @@ -0,0 +1,17 @@ +package com.swyp.catsgotogedog.mypage.domain.response; + +import java.util.List; + +import com.swyp.catsgotogedog.content.domain.entity.ContentWish; +import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; + +public record ContentWishPageResponse( + List wishes, + Long totalElements, + int totalPages, + int currentPage, + int size, + boolean hasNext, + boolean hasPrevious +) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java new file mode 100644 index 0000000..2cceb25 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/ContentWishResponse.java @@ -0,0 +1,9 @@ +package com.swyp.catsgotogedog.mypage.domain.response; + +public record ContentWishResponse ( + int contentId, + String imageUrl, + String thumbnailUrl, + Boolean isWish +) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/LastViewHistoryResponse.java b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/LastViewHistoryResponse.java new file mode 100644 index 0000000..f7d7864 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/domain/response/LastViewHistoryResponse.java @@ -0,0 +1,22 @@ +package com.swyp.catsgotogedog.mypage.domain.response; + +public record LastViewHistoryResponse ( + Integer contentId, + String contentTitle, + String imageUrl, + String thumbnailUrl, + Boolean isWish +) { + public LastViewHistoryResponse( + Integer contentId, + String contentTitle, + String imageUrl, + String thumbnailUrl, + Boolean isWish) { + this.contentId = contentId; + this.contentTitle = contentTitle; + this.imageUrl = imageUrl; + this.thumbnailUrl = thumbnailUrl; + this.isWish = isWish; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/repository/.gitkeep b/src/main/java/com/swyp/catsgotogedog/mypage/repository/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/repository/MyPageHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/mypage/repository/MyPageHistoryRepository.java new file mode 100644 index 0000000..f4c4b32 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/repository/MyPageHistoryRepository.java @@ -0,0 +1,15 @@ +package com.swyp.catsgotogedog.mypage.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistoryId; + +public interface MyPageHistoryRepository extends JpaRepository { + @Query("SELECT lvh FROM LastViewHistory lvh WHERE lvh.user.userId = :userId ORDER BY lvh.lastViewedAt DESC") + List findAllByUserId(Integer userId, Pageable pageable); +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java new file mode 100644 index 0000000..3a6f18e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageHistoryService.java @@ -0,0 +1,98 @@ +package com.swyp.catsgotogedog.mypage.service; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.content.domain.entity.ContentWish; +import com.swyp.catsgotogedog.content.repository.ContentWishRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.mypage.domain.entity.LastViewHistory; +import com.swyp.catsgotogedog.mypage.domain.response.ContentWishPageResponse; +import com.swyp.catsgotogedog.mypage.domain.response.ContentWishResponse; +import com.swyp.catsgotogedog.mypage.domain.response.LastViewHistoryResponse; +import com.swyp.catsgotogedog.mypage.repository.MyPageHistoryRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MyPageHistoryService { + + private final MyPageHistoryRepository myPageHistoryRepository; + private final UserRepository userRepository; + private final ContentWishRepository contentWishRepository; + + public List fetchLastViewHistory(String stringUserId) { + Integer userId = Integer.parseInt(stringUserId); + validateUser(userId); + + Pageable pageable = PageRequest.of(0, 20); + + List histories = myPageHistoryRepository.findAllByUserId(userId, pageable); + + if(histories.isEmpty()) { + return Collections.emptyList(); + } + + List contentIdsFromHistory = histories.stream() + .map(history -> history.getContent().getContentId()) + .toList(); + + Set wishedContentIds = contentWishRepository.findWishedContentIdsByUserIdAndContentIds(userId, contentIdsFromHistory); + + return histories.stream() + .map(history -> { + boolean isWished = wishedContentIds.contains(history.getContent().getContentId()); + return new LastViewHistoryResponse( + history.getContent().getContentId(), + history.getContent().getTitle(), + history.getContent().getImage(), + history.getContent().getThumbImage(), + isWished + ); + }) + .toList(); + } + + public ContentWishPageResponse fetchWishLists(String stringUserId, Pageable pageable) { + Integer userId = Integer.parseInt(stringUserId); + validateUser(userId); + + Pageable wishPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); + + Page wishPage = contentWishRepository.findAllByUserId(userId, wishPageable); + + return new ContentWishPageResponse( + wishPage.stream() + .map(wish -> new ContentWishResponse( + wish.getContent().getContentId(), + wish.getContent().getImage(), + wish.getContent().getThumbImage(), + Boolean.TRUE + )).toList(), + wishPage.getTotalElements(), + wishPage.getTotalPages(), + wishPage.getNumber(), + wishPage.getSize(), + wishPage.hasNext(), + wishPage.hasPrevious() + ); + } + + private void validateUser(Integer userId) { + userRepository.findById(userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageService.java b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageService.java new file mode 100644 index 0000000..849aba8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/mypage/service/MyPageService.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.mypage.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MyPageService { +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java new file mode 100644 index 0000000..d11a46d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetController.java @@ -0,0 +1,64 @@ +package com.swyp.catsgotogedog.pet.controller; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.pet.domain.entity.Pet; +import com.swyp.catsgotogedog.pet.domain.request.PetProfileRequest; +import com.swyp.catsgotogedog.pet.domain.response.PetProfileResponse; +import com.swyp.catsgotogedog.pet.service.PetService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; + +import jakarta.validation.Valid; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/pet") +@Slf4j +public class PetController implements PetControllerSwagger { + + private final PetService petService; + + @GetMapping("/profile") + public ResponseEntity> getAllProfiles( + @AuthenticationPrincipal String userId) { + List pets = petService.getAllPets(userId); + List response = pets.stream() + .map(PetProfileResponse::from) + .toList(); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("반려동물 프로필 목록 조회 성공", response)); + } + + @PostMapping("/profile") + public ResponseEntity> createProfile( + @AuthenticationPrincipal String userId, + @Valid @ModelAttribute PetProfileRequest petProfileRequest) { + petService.create(userId, petProfileRequest); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("반려동물 프로필 생성 성공", null)); + } + + @PatchMapping("/profile/{petId}") + public ResponseEntity> updateProfile( + @AuthenticationPrincipal String userId, + @PathVariable int petId, + @Valid @ModelAttribute PetProfileRequest petProfileRequest) { + petService.updateById(userId, petId, petProfileRequest); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("반려동물 프로필 수정 성공", null)); + } + + @DeleteMapping("/profile/{petId}") + public ResponseEntity> deleteProfile( + @AuthenticationPrincipal String userId, + @PathVariable int petId) { + petService.deleteById(userId, petId); + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("반려동물 프로필 삭제 성공", null)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java new file mode 100644 index 0000000..7d89e33 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/controller/PetControllerSwagger.java @@ -0,0 +1,112 @@ +package com.swyp.catsgotogedog.pet.controller; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.pet.domain.request.PetProfileRequest; +import com.swyp.catsgotogedog.pet.domain.response.PetProfileResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Pet", description = "반려동물 관련 API") +public interface PetControllerSwagger { + + @Operation( + summary = "반려동물 프로필 목록 조회", + description = "인증된 사용자의 모든 반려동물 프로필 목록을 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "반려동물 프로필 목록 조회 성공", + content = @Content(schema = @Schema(implementation = PetProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> getAllProfiles( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId + ); + + @Operation( + summary = "반려동물 프로필 등록", + description = """ + 사용자의 새로운 반려동물 프로필을 등록합니다. 반려동물의 정보와 이미지를 함께 업로드할 수 있습니다. 최대 10마리까지 등록 가능합니다.
+ 사진을 제외한 모든 정보는 필수로 입력해야 합니다.
+ 반려동물 크기는 소형, 중형, 대형 중 하나를 선택해야 합니다.""" + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "반려동물 프로필 생성 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터 또는 반려동물 등록 제한 초과 (최대 10마리)", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> createProfile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "반려동물 프로필 등록 정보", required = true) + PetProfileRequest petProfileRequest + ); + + @Operation( + summary = "반려동물 프로필 수정", + description = """ + 등록된 반려동물의 프로필 정보를 수정합니다. 본인의 반려동물만 수정할 수 있습니다.
+ 사진을 제외한 모든 정보는 필수로 입력해야 합니다.
+ 반려동물 크기는 소형, 중형, 대형 중 하나를 선택해야 합니다.""" + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "반려동물 프로필 수정 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "수정 권한이 없음 (본인의 반려동물이 아님)", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "반려동물을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> updateProfile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "수정할 반려동물 ID", required = true) + int petId, + @Parameter(description = "반려동물 프로필 수정 정보", required = true) + PetProfileRequest petProfileRequest + ); + + @Operation( + summary = "반려동물 프로필 삭제", + description = "등록된 반려동물의 프로필을 삭제합니다. 본인의 반려동물만 삭제할 수 있습니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "반려동물 프로필 삭제 성공", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "삭제 권한이 없음 (본인의 반려동물이 아님)", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "반려동물을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> deleteProfile( + @Parameter(description = "인증된 사용자 ID", hidden = true) + String userId, + @Parameter(description = "삭제할 반려동물 ID", required = true) + int petId + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java new file mode 100644 index 0000000..506cd07 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/Pet.java @@ -0,0 +1,50 @@ +package com.swyp.catsgotogedog.pet.domain.entity; + +import java.time.LocalDate; + +import com.swyp.catsgotogedog.User.domain.entity.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Pet { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "pet_id") + private int petId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private char gender; + private LocalDate birth; + private String type; + private boolean fierceDog; + @ManyToOne + @JoinColumn(name = "size_id", nullable = false) + private PetSize sizeId; + private String name; + private String imageFilename; + private String imageUrl; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetGuide.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetGuide.java new file mode 100644 index 0000000..7cfa0a0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetGuide.java @@ -0,0 +1,63 @@ +package com.swyp.catsgotogedog.pet.domain.entity; + +import com.swyp.catsgotogedog.content.domain.entity.Content; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "pet_guide") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PetGuide { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "pet_guide_id") + private Integer petGuideId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private Content content; + + @Column(name = "accident_prep", length = 50) + private String accidentPrep; + + @Column(name = "available_facility", length = 50) + private String availableFacility; + + @Column(name = "provided_item", length = 50) + private String providedItem; + + @Column(name = "etc_info") + private String etcInfo; + + @Column(name = "purchasable_item", length = 50) + private String purchasableItem; + + @Column(name = "allowed_pet_type", length = 255) + private String allowedPetType; + + @Column(name = "rent_item", length = 50) + private String rentItem; + + @Column(name = "pet_prep", length = 50) + private String petPrep; + + @Column(name = "with_pet", length = 50) + private String withPet; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetSize.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetSize.java new file mode 100644 index 0000000..202dc51 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/entity/PetSize.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.pet.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PetSize { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "size_id") + private int sizeId; + private String size; + private String sizeTooltip; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/request/.gitkeep b/src/main/java/com/swyp/catsgotogedog/pet/domain/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java new file mode 100644 index 0000000..9f951dd --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/request/PetProfileRequest.java @@ -0,0 +1,42 @@ +package com.swyp.catsgotogedog.pet.domain.request; + +import com.swyp.catsgotogedog.pet.domain.validation.ValidPetName; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +public class PetProfileRequest { + + @NotBlank(message = "반려동물 이름은 필수입니다.") + @ValidPetName + private String name; + + @NotBlank(message = "반려동물 성별은 필수입니다.") + @Pattern(regexp = "[MF]", message = "반려동물 성별은 M(수컷) 또는 F(암컷)이어야 합니다.") + private String gender; + + @NotNull(message = "반려동물 생년월일은 필수입니다.") + private LocalDate birth; + + @Size(min = 1, max = 20, message = "반려동물 종류는 최소 1자, 최대 20자까지 입력 가능합니다.") + @Pattern(regexp = "[가-힣a-zA-Z\\s()]+", message = "반려동물 종류는 한글, 영문, 공백, 소괄호만 입력 가능합니다.") + @NotBlank(message = "반려동물 종류는 필수입니다.") + private String type; + + @NotNull(message = "맹견 여부는 필수입니다.") + private boolean fierceDog; + + @NotBlank(message = "반려동물 크기는 필수입니다.") + private String size; + + private List image; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetGuideResponse.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetGuideResponse.java new file mode 100644 index 0000000..3f97adb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetGuideResponse.java @@ -0,0 +1,31 @@ +package com.swyp.catsgotogedog.pet.domain.response; + +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; +import lombok.Builder; + +@Builder +public class PetGuideResponse { + private String accidentPrep; + private String availableFacility; + private String providedItem; + private String etcInfo; + private String purchasableItem; + private String allowedPetType; + private String rentItem; + private String petPrep; + private String withPet; + + public static PetGuideResponse from(PetGuide e) { + return PetGuideResponse.builder() + .accidentPrep(e.getAccidentPrep()) + .availableFacility(e.getAvailableFacility()) + .providedItem(e.getProvidedItem()) + .etcInfo(e.getEtcInfo()) + .purchasableItem(e.getPurchasableItem()) + .allowedPetType(e.getAllowedPetType()) + .rentItem(e.getRentItem()) + .petPrep(e.getPetPrep()) + .withPet(e.getWithPet()) + .build(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java new file mode 100644 index 0000000..54cc44e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/response/PetProfileResponse.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.pet.domain.response; + +import com.swyp.catsgotogedog.pet.domain.entity.Pet; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PetProfileResponse { + private int petId; + private String name; + private char gender; + private LocalDate birth; + private String type; + private boolean fierceDog; + private String size; + private String imageFilename; + private String imageUrl; + + public static PetProfileResponse from(Pet pet) { + return PetProfileResponse.builder() + .petId(pet.getPetId()) + .name(pet.getName()) + .gender(pet.getGender()) + .birth(pet.getBirth()) + .type(pet.getType()) + .fierceDog(pet.isFierceDog()) + .size(pet.getSizeId().getSize()) + .imageFilename(pet.getImageFilename()) + .imageUrl(pet.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java new file mode 100644 index 0000000..16a6ee5 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/PetNameValidator.java @@ -0,0 +1,91 @@ +package com.swyp.catsgotogedog.pet.domain.validation; + +import com.swyp.catsgotogedog.common.util.perspectiveApi.service.ToxicityCheckService; +import com.swyp.catsgotogedog.common.util.perspectiveApi.dto.ToxicityCheckResult; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.regex.Pattern; + +@Component +@RequiredArgsConstructor +public class PetNameValidator implements ConstraintValidator { + + private final ToxicityCheckService toxicityCheckService; + + // 허용된 특수문자: 쉼표(,), 마침표(.), 작은따옴표(') + private static final Pattern ALLOWED_SPECIAL_CHARS = Pattern.compile("[,.'_]"); + + // 허용되지 않은 문자 체크 (숫자 추가, 한글 자음/모음은 3번에서 별도 처리) + private static final Pattern FORBIDDEN_CHARS = Pattern.compile("[^가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9,.'_]"); + + // 특수문자 연속 사용 체크 + private static final Pattern CONSECUTIVE_SPECIAL_CHARS = Pattern.compile("[,.'_]{2,}"); + + // 한글 불완전한 단어 체크 (자음만 또는 모음만) + private static final Pattern INCOMPLETE_KOREAN = Pattern.compile(".*[ㄱ-ㅎㅏ-ㅣ].*"); + + // 영어 자음 또는 모음 4회 이상 연속 + private static final Pattern CONSECUTIVE_CONSONANTS = Pattern.compile(".*[bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ]{4,}.*"); + private static final Pattern CONSECUTIVE_VOWELS = Pattern.compile(".*[aeiouAEIOU]{4,}.*"); + + @Override + public void initialize(ValidPetName constraintAnnotation) { + // 초기화 로직 (필요시) + } + + @Override + public boolean isValid(String name, ConstraintValidatorContext context) { + if (name == null || name.trim().isEmpty()) { + return true; // @NotBlank에서 처리 + } + + String trimmedName = name.trim(); + + // 1. 허용되지 않은 특수문자 체크 + if (FORBIDDEN_CHARS.matcher(trimmedName).find()) { + setCustomMessage(context, "한글, 영어, 숫자, 쉼표(,), 마침표(.), 작은따옴표(')만 사용할 수 있습니다."); + return false; + } + + // 2. 특수문자 연속 사용 체크 + if (CONSECUTIVE_SPECIAL_CHARS.matcher(trimmedName).find()) { + setCustomMessage(context, "특수문자는 연속으로 사용할 수 없습니다."); + return false; + } + + // 3. 한글 불완전한 단어 체크 (자음만 또는 모음만) + if (INCOMPLETE_KOREAN.matcher(trimmedName).find()) { + setCustomMessage(context, "올바른 단어를 입력해주세요."); + return false; + } + + // 4. 영어 자음 또는 모음 4회 이상 연속 체크 + if (CONSECUTIVE_CONSONANTS.matcher(trimmedName).find() || + CONSECUTIVE_VOWELS.matcher(trimmedName).find()) { + setCustomMessage(context, "올바른 단어를 입력해주세요."); + return false; + } + + // 5. 비속어 필터링 체크 (반려동물 이름 기준치: 0.8) + ToxicityCheckResult toxicityResult = toxicityCheckService.checkPetName(trimmedName); + if (!toxicityResult.passed()) { + if (toxicityResult.errorMessage() != null) { + setCustomMessage(context, "반려동물 이름 검증 중 오류가 발생했습니다."); + } else { + setCustomMessage(context, "부적절한 반려동물 이름입니다. 다른 이름을 사용해주세요."); + } + return false; + } + + return true; + } + + private void setCustomMessage(ConstraintValidatorContext context, String message) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(message) + .addConstraintViolation(); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/ValidPetName.java b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/ValidPetName.java new file mode 100644 index 0000000..d115825 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/domain/validation/ValidPetName.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.pet.domain.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = PetNameValidator.class) +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPetName { + String message() default "반려동물 이름이 올바르지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java new file mode 100644 index 0000000..8ccfc67 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetGuideRepository.java @@ -0,0 +1,13 @@ +package com.swyp.catsgotogedog.pet.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.pet.domain.entity.PetGuide; + +import java.util.Optional; + +public interface PetGuideRepository extends JpaRepository { + boolean existsByContent_ContentId(int contentId); + + Optional findByContent_ContentId(int contentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java new file mode 100644 index 0000000..e238bcd --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetRepository.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.pet.repository; + +import com.swyp.catsgotogedog.pet.domain.entity.Pet; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PetRepository extends JpaRepository { + List findAllByUser_UserIdOrderByPetId(int userUserId); + int countByUser_UserId(int userId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/repository/PetSizeRepository.java b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetSizeRepository.java new file mode 100644 index 0000000..bed843f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/repository/PetSizeRepository.java @@ -0,0 +1,10 @@ +package com.swyp.catsgotogedog.pet.repository; + +import com.swyp.catsgotogedog.pet.domain.entity.PetSize; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PetSizeRepository extends JpaRepository { + Optional findBySize(String size); +} diff --git a/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java new file mode 100644 index 0000000..843316f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/pet/service/PetService.java @@ -0,0 +1,153 @@ +package com.swyp.catsgotogedog.pet.service; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.util.image.storage.ImageStorageService; +import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; +import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.global.exception.ForbiddenAccessException; +import com.swyp.catsgotogedog.global.exception.MemberNotFoundException; +import com.swyp.catsgotogedog.global.exception.PetLimitExceededException; +import com.swyp.catsgotogedog.global.exception.ResourceNotFoundException; +import com.swyp.catsgotogedog.global.exception.images.ImageUploadException; +import com.swyp.catsgotogedog.pet.domain.entity.Pet; +import com.swyp.catsgotogedog.pet.domain.entity.PetSize; +import com.swyp.catsgotogedog.pet.domain.request.PetProfileRequest; +import com.swyp.catsgotogedog.pet.repository.PetRepository; +import com.swyp.catsgotogedog.pet.repository.PetSizeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PetService { + + private final PetRepository petRepository; + private final PetSizeRepository petSizeRepository; + private final UserRepository userRepository; + private final ImageStorageService imageStorageService; + + private final String DEFAULT_IMAGE_URL = "https://kr.object.ncloudstorage.com/catsgotogedogbucket/profile/default_pet_image.png"; + + public List getAllPets(String userId) { + return petRepository.findAllByUser_UserIdOrderByPetId(Integer.parseInt(userId)); + } + + public void create(String userId, PetProfileRequest petProfileRequest) { + User user = findUserById(userId); + validatePetLimit(userId); + + PetSize petSize = findPetSizeBySize(petProfileRequest.getSize()); + + String imageUrl = DEFAULT_IMAGE_URL; + String imageFilename = null; + + // 이미지 업로드 처리 (이미지가 있는 경우) + if (hasImage(petProfileRequest)) { + ImageInfo imageInfo = uploadImage(petProfileRequest); + imageUrl = imageInfo.url(); + imageFilename = imageInfo.key(); + } + + Pet pet = Pet.builder() + .user(user) + .name(petProfileRequest.getName()) + .gender(petProfileRequest.getGender().charAt(0)) + .birth(petProfileRequest.getBirth()) + .type(petProfileRequest.getType()) + .fierceDog(petProfileRequest.isFierceDog()) + .sizeId(petSize) + .imageUrl(imageUrl) + .imageFilename(imageFilename) + .build(); + + petRepository.save(pet); + } + + public void updateById(String userId, int petId, PetProfileRequest petProfileRequest) { + + Pet pet = findPetByIdAndUserId(petId, userId); + PetSize petSize = findPetSizeBySize(petProfileRequest.getSize()); + + // 새 이미지가 업로드된 경우 기존 이미지 삭제 후 새 이미지 업로드 + if (hasImage(petProfileRequest)) { + deleteExistingImageIfExists(pet); + ImageInfo imageInfo = uploadImage(petProfileRequest); + pet.setImageUrl(imageInfo.url()); + pet.setImageFilename(imageInfo.key()); + } + + // 펫 정보 업데이트 + pet.setName(petProfileRequest.getName()); + pet.setGender(petProfileRequest.getGender().charAt(0)); + pet.setBirth(petProfileRequest.getBirth()); + pet.setType(petProfileRequest.getType()); + pet.setFierceDog(petProfileRequest.isFierceDog()); + pet.setSizeId(petSize); + + petRepository.save(pet); + } + + public void deleteById(String userId, int petId) { + Pet pet = findPetByIdAndUserId(petId, userId); + + deleteExistingImageIfExists(pet); + petRepository.delete(pet); + } + + private User findUserById(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new MemberNotFoundException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private void validatePetLimit(String userId) { + int petCount = petRepository.countByUser_UserId(Integer.parseInt(userId)); + if (petCount >= 10) { + throw new PetLimitExceededException(ErrorCode.PET_LIMIT_EXCEEDED); + } + } + + private Pet findPetByIdAndUserId(int petId, String userId) { + Pet pet = petRepository.findById(petId) + .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.PET_NOT_FOUND)); + + if (pet.getUser().getUserId() != Integer.parseInt(userId)) { + throw new ForbiddenAccessException(ErrorCode.FORBIDDEN_ACCESS); + } + + return pet; + } + + private PetSize findPetSizeBySize(String size) { + return petSizeRepository.findBySize(size) + .orElseThrow(() -> new ResourceNotFoundException(ErrorCode.PET_SIZE_NOT_FOUND)); + } + + private boolean hasImage(PetProfileRequest request) { + return request.getImage() != null && !request.getImage().isEmpty(); + } + + private ImageInfo uploadImage(PetProfileRequest request) { + try { + List imageInfos = imageStorageService.upload( + request.getImage(), "pet_profile/", ImageUploadType.PROFILE); + return imageInfos.get(0); + } catch (Exception e) { + throw new ImageUploadException(ErrorCode.IMAGE_UPLOAD_FAILED); + } + } + + private void deleteExistingImageIfExists(Pet pet) { + if (StringUtils.hasText(pet.getImageFilename())) { + imageStorageService.delete(pet.getImageFilename()); + pet.setImageFilename(null); + } + pet.setImageUrl(DEFAULT_IMAGE_URL); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java new file mode 100644 index 0000000..64d82bb --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewController.java @@ -0,0 +1,178 @@ +package com.swyp.catsgotogedog.review.controller; + +import java.util.List; + +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.domain.response.ContentReviewPageResponse; +import com.swyp.catsgotogedog.review.repository.ReviewRepository; +import com.swyp.catsgotogedog.review.service.ReviewRecommendService; +import com.swyp.catsgotogedog.review.service.ReviewService; + +import io.jsonwebtoken.io.IOException; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/review") +@Slf4j +public class ReviewController implements ReviewControllerSwagger { + + private final ReviewService reviewService; + private final ReviewRecommendService reviewRecommendService; + private final ReviewRepository reviewRepository; + + // 리뷰 작성 + @Override + @PostMapping(value = "/{contentId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> createReview( + @PathVariable int contentId, + @AuthenticationPrincipal String userId, + @ModelAttribute @Valid CreateReviewRequest createReviewRequest, + @RequestParam(value = "images", required = false)List images) throws IOException { + + reviewService.createReview(contentId, userId, createReviewRequest, images); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(CatsgotogedogApiResponse.success("리뷰 생성 성공", null)); + } + + // 리뷰 수정 + @Override + @PutMapping(value = "/{reviewId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> updateReview( + @PathVariable int reviewId, + @AuthenticationPrincipal String userId, + @Valid @ModelAttribute @ParameterObject CreateReviewRequest createReviewRequest, + @RequestParam(value = "images", required = false)List images) { + + reviewService.updateReview(reviewId, userId, createReviewRequest, images); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 수정 성공", null) + ); + } + + // 리뷰 삭제 + @Override + @DeleteMapping(value = "/{reviewId}") + public ResponseEntity> deleteReview( + @PathVariable int reviewId, + @AuthenticationPrincipal String userId) { + + reviewService.deleteReview(reviewId, userId); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 삭제 성공", null) + ); + } + + // 리뷰 이미지 삭제 + @Override + @DeleteMapping(value = "/{reviewId}/image/{imageId}") + public ResponseEntity> deleteReviewImage( + @PathVariable(name = "reviewId") int reviewId, + @PathVariable(name = "imageId") int imageId, + @AuthenticationPrincipal String userId) { + + reviewService.deleteReviewImage(reviewId, imageId, userId); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 이미지 삭제 성공", null) + ); + } + + // 컨텐츠별 리뷰 조회 + @Override + @GetMapping("/content/{contentId}") + public ResponseEntity> fetchReviewsByContentId( + @PathVariable int contentId, + @AuthenticationPrincipal String userId, + @RequestParam(defaultValue = "r") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "4") int size) { + + Pageable pageable = PageRequest.of(page, size); + + String actUserId = (userId != null && !userId.equals("anonymousUser")) ? userId : null; + ContentReviewPageResponse reviewResponses = reviewService.fetchReviewsByContentId(contentId, sort, pageable, actUserId); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 조회 성공", reviewResponses) + ); + } + + // 자신의 작성 리뷰 조회 + @Override + @GetMapping("/") + public ResponseEntity> fetchReviewsByUserId( + @AuthenticationPrincipal String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "4") int size) { + + Pageable pageable = PageRequest.of(page, size, Sort.Direction.DESC, "createdAt"); + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 조회 성공", + reviewService.fetchReviewsByUserId(userId, pageable)) + ); + } + + + // 리뷰 좋아요 + @Override + @PostMapping("/recommend/{reviewId}") + public ResponseEntity> recommendReview( + @AuthenticationPrincipal String userId, + @PathVariable int reviewId) { + + reviewRecommendService.recommendReview(reviewId, userId); + return ResponseEntity.ok(CatsgotogedogApiResponse.success("리뷰 좋아요 완료", null)); + } + + // 리뷰 좋아요 취소 + @Override + @DeleteMapping("/recommend/{reviewId}") + public ResponseEntity> cancelRecommendReview( + @AuthenticationPrincipal String userId, + @PathVariable int reviewId) { + + reviewRecommendService.cancelRecommendReview(reviewId, userId); + return ResponseEntity.ok(CatsgotogedogApiResponse.success("리뷰 좋아요 취소 완료", null)); + } + + // 자신의 특정 리뷰 조회 + @Override + @GetMapping("/{reviewId}") + public ResponseEntity> fetchReviewInformation( + @AuthenticationPrincipal String userId, + @PathVariable int reviewId) { + + return ResponseEntity.ok( + CatsgotogedogApiResponse.success("리뷰 조회 성공", reviewService.fetchReviewById(reviewId, userId))); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java new file mode 100644 index 0000000..0425a43 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewControllerSwagger.java @@ -0,0 +1,246 @@ +package com.swyp.catsgotogedog.review.controller; + +import java.util.List; + +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.domain.response.MyReviewResponse; +import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; + +import io.jsonwebtoken.io.IOException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Review", description = "리뷰 관련 API") +public interface ReviewControllerSwagger { + + @Operation( + summary = "리뷰를 작성합니다.", + description = "사용자 인증을 통해 리뷰를 작성합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "리뷰 작성 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "ContentId가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> createReview( + @Parameter(description = "리뷰를 작성할 컨텐츠 ID", required = true) + @PathVariable int contentId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @RequestPart(value = "createReviewRequest") + @ModelAttribute @ParameterObject CreateReviewRequest createReviewRequest, + + @Parameter(description = "이미지 업로드 (최대 3장)") + @RequestParam(value = "images") List images + ); + + @Operation( + summary = "작성 리뷰를 수정합니다.", + description = "사용자 인증을 통해 리뷰를 수정합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 수정 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "ContentId가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> updateReview( + @Parameter(description = "수정할 리뷰 ID", required = true) + @PathVariable int reviewId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @RequestPart(value = "createReviewRequest") + @ModelAttribute @ParameterObject CreateReviewRequest createReviewRequest, + + @Parameter(description = "이미지 업로드 (최대 3장)") + @RequestParam(value = "images") List images + ); + + @Operation( + summary = "작성한 리뷰를 삭제합니다.", + description = "사용자 인증을 통해 리뷰를 수정합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 삭제 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "접근 권한이 없음, 다른 사람의 리뷰 삭제시" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> deleteReview( + @Parameter(description = "삭제할 리뷰 ID", required = true) + @PathVariable int reviewId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String userId + ); + + @Operation( + summary = "작성한 리뷰의 이미지를 삭제합니다.", + description = "사용자 인증을 통해 리뷰의 이미지를 삭제합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "이미지 삭제 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "403", description = "접근 권한이 없음, 다른 사람의 리뷰 or 이미지 삭제시" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> deleteReviewImage( + @Parameter(description = "삭제할 리뷰 ID", required = true) + @PathVariable int reviewId, + + @Parameter(description = "삭제할 이미지 ID", required = true) + @PathVariable int imageId, + + @Parameter(hidden = true) + @AuthenticationPrincipal String userId + ); + + @Operation( + summary = "컨텐츠에 작성된 리뷰 목록을 조회합니다.", + description = "Bearer 키 입력시 현재 사용자가 좋아요 누른 리뷰 반환 값을 매핑하여 반환합니다." + + " 키 입력을 안할 경우 좋아요를 누른 리뷰인지 판단하지 않습니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 목록 조회 성공" + , content = @Content(schema = @Schema(implementation = ReviewResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "컨텐츠가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchReviewsByContentId( + @Parameter(description = "조회할 컨텐츠 ID", required = true) + @PathVariable int contentId, + @Parameter(description = "로그인 상태일 경우 자동 기입", required = false) + @AuthenticationPrincipal String userId, + @Parameter(description = "정렬 기준 (좋아요 순: r, 최신순: c, 기본: r)", required = false, + example = "r") + @RequestParam String sort, + @Parameter(description = "요청 페이지") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지당 결과 갯수") + @RequestParam(defaultValue = "4") int size); + + @Operation( + summary = "자신의 리뷰 목록을 조회합니다.", + description = "사용자 인증을 통해 리뷰 목록을 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 목록 조회 성공" + , content = @Content(schema = @Schema(implementation = ReviewResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchReviewsByUserId( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @Parameter(description = "요청 페이지") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지당 결과 갯수") + @RequestParam(defaultValue = "4") int size); + + @Operation( + summary = "특정 리뷰를 좋아요 처리.", + description = "사용자 인증을 통해 리뷰에 좋아요 기능." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 좋아요 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음, 이미 좋아요된 리뷰입니다." + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> recommendReview( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @Parameter(description = "리뷰 ID", required = true) + @PathVariable int reviewId); + + @Operation( + summary = "특정 리뷰를 좋아요 해제 처리.", + description = "사용자 인증을 통해 리뷰에 좋아요 해제 기능." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 좋아요 해제 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음, 좋아요 상태인 리뷰가 아닙니다." + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> cancelRecommendReview( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @Parameter(description = "리뷰 ID", required = true) + @PathVariable int reviewId); + + @Operation( + summary = "특정 리뷰 정보 조회.", + description = "사용자 인증을 통해 특정 리뷰 정보를 조회합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 조회 성공" + , content = @Content(schema = @Schema(implementation = MyReviewResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나 유효하지 않음, 이미 좋아요된 리뷰입니다." + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> fetchReviewInformation( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + @PathVariable int reviewId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportController.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportController.java new file mode 100644 index 0000000..c8879df --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportController.java @@ -0,0 +1,39 @@ +package com.swyp.catsgotogedog.review.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; +import com.swyp.catsgotogedog.review.service.ReviewReportService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/review/report") +@Slf4j +public class ReviewReportController implements ReviewReportControllerSwagger { + + private final ReviewReportService reviewReportService; + + @Override + @PostMapping(value = "/{reviewId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity> reportReview( + @AuthenticationPrincipal String userId, + @PathVariable int reviewId, + @RequestParam int reasonId) { + + reviewReportService.reportReview(reviewId, reasonId, userId); + + return ResponseEntity.ok(CatsgotogedogApiResponse.success( + "리뷰 신고 완료", null)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportControllerSwagger.java b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportControllerSwagger.java new file mode 100644 index 0000000..0d6ceea --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/controller/ReviewReportControllerSwagger.java @@ -0,0 +1,47 @@ +package com.swyp.catsgotogedog.review.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import com.swyp.catsgotogedog.global.CatsgotogedogApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Review", description = "리뷰 관련 API") +public interface ReviewReportControllerSwagger { + + @Operation( + summary = "특정 리뷰를 신고합니다.", + description = "사용자 인증을 통해 리뷰를 신고합니다." + ) + @SecurityRequirement(name = "bearer-key") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리뷰 삭제 성공" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 누락되거나, 이미 신고 처리된 리뷰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))), + @ApiResponse(responseCode = "404", description = "리뷰 또는 유저가 존재하지 않음" + , content = @Content(schema = @Schema(implementation = CatsgotogedogApiResponse.class))) + }) + ResponseEntity> reportReview( + @Parameter(hidden = true) + @AuthenticationPrincipal String userId, + + @Parameter(description = "신고할 리뷰 ID", required = true) + @PathVariable int reviewId, + + @Parameter(description = "신고 사유 ID", required = true) + @RequestBody int reasonId + ); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ContentReview.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ContentReview.java new file mode 100644 index 0000000..77341d0 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ContentReview.java @@ -0,0 +1,28 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import com.swyp.catsgotogedog.global.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.Getter; + +import java.math.BigDecimal; + +@Entity +@Getter +public class ContentReview extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int reviewId; + + private int userId; + + private int contentId; + + private String content; + + @Column(precision = 2, scale = 1) + private BigDecimal score; + +// private int like; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReportReason.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReportReason.java new file mode 100644 index 0000000..f5563c3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReportReason.java @@ -0,0 +1,21 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "report_reason") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ReportReason { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int reasonId; + private String content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java new file mode 100644 index 0000000..6e4510f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/Review.java @@ -0,0 +1,58 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import java.math.BigDecimal; +import java.util.List; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.global.BaseTimeEntity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Builder +@Table(name = "content_review") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Review extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int reviewId; + private int userId; + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "contentId") + private Content contentEntity; + + @Column(precision = 2, scale = 1) + @DecimalMin(value = "0.5", message = "별점은 0.5 이상이어야 합니다.") + @DecimalMax(value = "5.0", message = "별점은 5.0 이하이어야 합니다.") + private BigDecimal score; + + private int recommendedNumber; + + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true) + private List reviewImages; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewImage.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewImage.java new file mode 100644 index 0000000..dd3f710 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewImage.java @@ -0,0 +1,35 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ReviewImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int imageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reviewId") + private Review review; + + private String imageFilename; + + private String imageUrl; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java new file mode 100644 index 0000000..f4cef64 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewRecommendHistory.java @@ -0,0 +1,47 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import org.springframework.data.annotation.CreatedDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Setter +@Getter +@Table(name = "review_recommend_history", +uniqueConstraints = @UniqueConstraint(columnNames = {"userId", "reviewId"})) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class ReviewRecommendHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "recommend_history_id") + private int recommendHistoryId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; + + @Column(name = "user_id", nullable = false) + private int userId; + + @CreatedDate + @Column(updatable = false) + private String createdAt; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewReport.java b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewReport.java new file mode 100644 index 0000000..aff9e0f --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/entity/ReviewReport.java @@ -0,0 +1,43 @@ +package com.swyp.catsgotogedog.review.domain.entity; + +import com.swyp.catsgotogedog.User.domain.entity.User; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Table(name = "review_report") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class ReviewReport { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int reportId; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "review_id") + private Review review; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reason_id") + private ReportReason reportReason; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "user_id") + private User user; + +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java b/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java new file mode 100644 index 0000000..1adbed3 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/request/CreateReviewRequest.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.review.domain.request; + +import java.math.BigDecimal; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CreateReviewRequest { + + @NotNull(message = "별점을 입력해주세요") + @DecimalMin(value = "0.5", message = "별점은 0.5 이상이어야 합니다.") + @DecimalMax(value = "5.0", message = "별점은 5.0 이하이어야 합니다.") + @Schema(example = "3.5", description = "0.5 ~ 5.0 (.5 단위) 입력") + private BigDecimal score; + @NotEmpty(message = "리뷰 내용을 입력해주세요.") + @Schema(example = "리뷰 내용", description = "리뷰 내용 입력") + private String content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java new file mode 100644 index 0000000..7194cff --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ContentReviewPageResponse.java @@ -0,0 +1,25 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ContentReviewPageResponse ( + @Schema(description = "총 조회 갯수") + int totalElements, + @Schema(description = "총 페이지 갯수") + int totalPages, + @Schema(description = "현재 페이지") + int currentPage, + @Schema(description = "사이즈") + int size, + @Schema(description = "다음 존재 여부") + boolean hasNext, + @Schema(description = "이전 존재 여부") + boolean hasPrevious, + @Schema(description = "컨텐츠 리뷰 목록") + List reviews, + @Schema(description = "리뷰 전체 이미지 목록") + List reviewImages +) { +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java new file mode 100644 index 0000000..4d3becc --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewPageResponse.java @@ -0,0 +1,22 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MyReviewPageResponse( + @Schema(description = "총 조회 갯수") + int totalElements, + @Schema(description = "총 페이지") + int totalPages, + @Schema(description = "현재 조회 페이지") + int currentPage, + @Schema(description = "사이즈") + int size, + @Schema(description = "다음 존재 여부") + boolean hasNext, + @Schema(description = "이전 존재 여부") + boolean hasPrevious, + @Schema(description = "작성 리뷰 목록") + List reviews +) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java new file mode 100644 index 0000000..f1a86ba --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/MyReviewResponse.java @@ -0,0 +1,26 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MyReviewResponse( + @Schema(description = "컨텐츠 ID") + int contentId, + @Schema(description = "컨텐츠 제목") + String contentTitle, + @Schema(description = "리뷰 ID") + int reviewId, + @Schema(description = "리뷰 내용") + String content, + @Schema(description = "별점") + BigDecimal score, + @Schema(description = "좋아요 수") + int recommendedNumber, + @Schema(description = "작성일시") + LocalDateTime createdAt, + @Schema(description = "이미지 목록") + List images +) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReportReasonResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReportReasonResponse.java new file mode 100644 index 0000000..9337b93 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReportReasonResponse.java @@ -0,0 +1,11 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public class ReportReasonResponse { + @Schema(description = "신고 사유 ID") + private int reasonId; + + @Schema(description = "신고 사유") + private String content; +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewImageResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewImageResponse.java new file mode 100644 index 0000000..ec324de --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewImageResponse.java @@ -0,0 +1,14 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; + +public record ReviewImageResponse ( + int imageId, String imageUrl +) { + public static ReviewImageResponse from(ReviewImage reviewImage) { + return new ReviewImageResponse( + reviewImage.getImageId(), + reviewImage.getImageUrl() + ); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java new file mode 100644 index 0000000..f8d8368 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewResponse.java @@ -0,0 +1,34 @@ +package com.swyp.catsgotogedog.review.domain.response; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ReviewResponse ( + @Schema(description = "조회 컨텐츠 ID") + int contentId, + @Schema(description = "리뷰 ID") + int reviewId, + @Schema(description = "리뷰 작성자 ID") + int userId, + @Schema(description = "리뷰 작성자 닉네임") + String displayName, + @Schema(description = "리뷰 작성자 프로필 이미지") + String profileImageUrl, + @Schema(description = "리뷰 내용") + String content, + @Schema(description = "별점") + BigDecimal score, + @Schema(description = "리뷰 작성일시") + LocalDateTime createdAt, + @Schema(description = "리뷰 추천수") + int recommendedNumber, + @Schema(description = "자신의 추천 여부") + boolean isRecommended, + @Schema(description = "신고 누적 5회 이상 리뷰 여부") + boolean isBlind, + @Schema(description = "리뷰 이미지 목록") + List images +) {} diff --git a/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewSortType.java b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewSortType.java new file mode 100644 index 0000000..ad11d19 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/domain/response/ReviewSortType.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.review.domain.response; + +public enum ReviewSortType { + LATEST("c"), + RECOMMENDED("r"); + + private final String value; + + private ReviewSortType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java new file mode 100644 index 0000000..c4a89b4 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ContentReviewRepository.java @@ -0,0 +1,26 @@ +package com.swyp.catsgotogedog.review.repository; + +import com.swyp.catsgotogedog.review.domain.entity.ContentReview; +import com.swyp.catsgotogedog.review.repository.projection.AvgScoreProjection; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; + +import java.util.List; + + +public interface ContentReviewRepository extends JpaRepository { + ContentReview findByContentId(int contentId); + + @Query("select avg(cr.score) from ContentReview cr where cr.contentId = :contentId") + Double findAvgScoreByContentId(@Param("contentId") int contentId); + + @Query(""" + SELECT cr.contentId AS contentId, + AVG(cr.score) AS avgScore + FROM ContentReview cr + WHERE cr.contentId IN :contentIds + GROUP BY cr.contentId + """) + List findAvgScoreByContentIdIn(@Param("contentIds") List contentIds); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReportReasonRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReportReasonRepository.java new file mode 100644 index 0000000..b6c43c8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReportReasonRepository.java @@ -0,0 +1,8 @@ +package com.swyp.catsgotogedog.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.review.domain.entity.ReportReason; + +public interface ReportReasonRepository extends JpaRepository { +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java new file mode 100644 index 0000000..688965d --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewImageRepository.java @@ -0,0 +1,12 @@ +package com.swyp.catsgotogedog.review.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; + +public interface ReviewImageRepository extends JpaRepository { + List findByReview(Review review); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java new file mode 100644 index 0000000..21361f8 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRecommendHistoryRepository.java @@ -0,0 +1,24 @@ +package com.swyp.catsgotogedog.review.repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewRecommendHistory; + +public interface ReviewRecommendHistoryRepository extends JpaRepository { + + @Query("SELECT r.review.reviewId FROM ReviewRecommendHistory r " + + "WHERE r.userId = :userId " + + "AND r.review IN :reviews") + Set findRecommendedReviewIdsByUserIdAndReviewIds( + @Param("userId") int userId, + @Param("reviews") List reviews); + + Optional findReviewRecommendHistoryByReviewAndUserId(Review review, int userId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewReportRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewReportRepository.java new file mode 100644 index 0000000..21d2b35 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewReportRepository.java @@ -0,0 +1,16 @@ +package com.swyp.catsgotogedog.review.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewReport; + +public interface ReviewReportRepository extends JpaRepository { + Optional findByUserAndReview(User reporter, Review targetReview); + + List findByReview(Review review); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java new file mode 100644 index 0000000..7707a9e --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/ReviewRepository.java @@ -0,0 +1,51 @@ +package com.swyp.catsgotogedog.review.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.review.domain.entity.Review; + +public interface ReviewRepository extends JpaRepository { + /** + * reviewId와 userId 를 통한 리뷰 컬렉션 조회 + * @param reviewId + * @param userId + * @return Optional + */ + @Query("SELECT r FROM Review r WHERE r.reviewId = :reviewId AND r.userId = :userId") + Optional findByIdAndUserId(@Param("reviewId") int reviewId, @Param("userId") String userId); + + /** + * contentId와 pageable 요소를 통한 페이징 Review 목록 + * @param contentId + * @param pageable + * @return 페이지네이션 Review + */ + @Query("SELECT DISTINCT r FROM Review r " + + "LEFT JOIN FETCH r.reviewImages " + + "WHERE r.contentEntity.contentId = :contentId") + Page findByContentIdWithUserAndReviewImages( + @Param("contentId") int contentId, + Pageable pageable); + + /** + * userId와 pageable 요소를 통한 자신이 작성한 페이징 Review 목록 + * @param userId + * @param pageable + * @return 페이지네이션 Review + */ + @EntityGraph(attributePaths = {"reviewImages"}) + Page findByUserId(int userId, Pageable pageable); + + List findByContentEntity(Content contentEntity); + + List findByContentEntityContentId(int contentEntityContentId); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/repository/projection/AvgScoreProjection.java b/src/main/java/com/swyp/catsgotogedog/review/repository/projection/AvgScoreProjection.java new file mode 100644 index 0000000..76fad4a --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/repository/projection/AvgScoreProjection.java @@ -0,0 +1,6 @@ +package com.swyp.catsgotogedog.review.repository.projection; + +public interface AvgScoreProjection { + int getContentId(); + Double getAvgScore(); +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java new file mode 100644 index 0000000..822e536 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewRecommendService.java @@ -0,0 +1,71 @@ +package com.swyp.catsgotogedog.review.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewRecommendHistory; +import com.swyp.catsgotogedog.review.repository.ReviewRecommendHistoryRepository; +import com.swyp.catsgotogedog.review.repository.ReviewRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewRecommendService { + + private final UserRepository userRepository; + private final ReviewRepository reviewRepository; + private final ReviewRecommendHistoryRepository reviewRecommendHistoryRepository; + + // 좋아요 처리 + @Transactional + public void recommendReview(int reviewId, String strUserId) { + int userId = Integer.parseInt(strUserId); + User user = validateUser(userId); + Review targetReview = validateReview(reviewId); + + reviewRecommendHistoryRepository.findReviewRecommendHistoryByReviewAndUserId(targetReview, userId) + .ifPresent(reviewRecommendHistory -> { + throw new CatsgotogedogException(ErrorCode.ALREADY_RECOMMENDED); + }); + reviewRecommendHistoryRepository.save(ReviewRecommendHistory.builder() + .userId(user.getUserId()) + .review(targetReview) + .build()); + + targetReview.setRecommendedNumber(targetReview.getRecommendedNumber() + 1); + } + + // 좋아요 해제 처리 + @Transactional + public void cancelRecommendReview(int reviewId, String strUserId) { + int userId = Integer.parseInt(strUserId); + User user = validateUser(userId); + Review targetReview = validateReview(reviewId); + + reviewRecommendHistoryRepository.findReviewRecommendHistoryByReviewAndUserId(targetReview, userId) + .ifPresentOrElse(reviewRecommendHistoryRepository::delete, + () -> { + throw new CatsgotogedogException(ErrorCode.NOT_RECOMMENDED_REVIEW); + } + ); + targetReview.setRecommendedNumber(targetReview.getRecommendedNumber() - 1); + } + + private User validateUser(int userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private Review validateReview(int reviewId) { + return reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewReportService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewReportService.java new file mode 100644 index 0000000..51f1560 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewReportService.java @@ -0,0 +1,90 @@ +package com.swyp.catsgotogedog.review.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.review.domain.entity.ReportReason; +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewReport; +import com.swyp.catsgotogedog.review.repository.ReportReasonRepository; +import com.swyp.catsgotogedog.review.repository.ReviewReportRepository; +import com.swyp.catsgotogedog.review.repository.ReviewRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewReportService { + + private final int REPORT_BLIND_COUNTS = 5; + + private final UserRepository userRepository; + private final ReportReasonRepository reportReasonRepository; + private final ReviewReportRepository reviewReportRepository; + private final ReviewRepository reviewRepository; + + // 리뷰 신고 + public void reportReview(Integer reviewId, Integer reviewReportId, String stringUserId) { + User reporter = validateUser(stringUserId); + + ReportReason reason = reportReasonRepository.findById(reviewReportId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REPORT_REASON_NOT_FOUND)); + + Review targetReview = reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + + if(reporter.getUserId() == targetReview.getUserId()) { + throw new CatsgotogedogException(ErrorCode.OWN_REVIEW_CANT_REPORT); + } + + reviewReportRepository.findByUserAndReview(reporter, targetReview) + .ifPresentOrElse( + // 신고 내역이 존재하여 신고 처리 안됨. + existReport -> { + throw new CatsgotogedogException(ErrorCode.ALREADY_REPORTED); + }, + // 신고 내역이 없을 경우 신고 처리 + () -> reviewReportRepository.save( + ReviewReport.builder() + .user(reporter) + .reportReason(reason) + .review(targetReview) + .build())); + } + + // 특정 리뷰 블라인드 여부 + public boolean isBlindReview(Integer reviewId) { + Review targetReview = reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + + List reports = reviewReportRepository.findByReview(targetReview); + + Map reportCountByReason = reports.stream() + .collect(Collectors.groupingBy( + ReviewReport::getReportReason, + Collectors.counting() + )); + + return reportCountByReason.values().stream() + .anyMatch(count -> count >= REPORT_BLIND_COUNTS); + } + + + public List fetchReasons() { + return reportReasonRepository.findAll(); + } + + private User validateUser(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java new file mode 100644 index 0000000..d9c7490 --- /dev/null +++ b/src/main/java/com/swyp/catsgotogedog/review/service/ReviewService.java @@ -0,0 +1,315 @@ +package com.swyp.catsgotogedog.review.service; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.swyp.catsgotogedog.User.domain.entity.User; +import com.swyp.catsgotogedog.User.repository.UserRepository; +import com.swyp.catsgotogedog.common.util.image.storage.ImageStorageService; +import com.swyp.catsgotogedog.common.util.image.storage.dto.ImageInfo; +import com.swyp.catsgotogedog.common.util.image.validator.ImageUploadType; +import com.swyp.catsgotogedog.content.domain.entity.Content; +import com.swyp.catsgotogedog.content.repository.ContentRepository; +import com.swyp.catsgotogedog.global.exception.CatsgotogedogException; +import com.swyp.catsgotogedog.global.exception.ErrorCode; +import com.swyp.catsgotogedog.review.domain.entity.Review; +import com.swyp.catsgotogedog.review.domain.entity.ReviewImage; +import com.swyp.catsgotogedog.review.domain.entity.ReviewRecommendHistory; +import com.swyp.catsgotogedog.review.domain.request.CreateReviewRequest; +import com.swyp.catsgotogedog.review.domain.response.ContentReviewPageResponse; +import com.swyp.catsgotogedog.review.domain.response.MyReviewPageResponse; +import com.swyp.catsgotogedog.review.domain.response.MyReviewResponse; +import com.swyp.catsgotogedog.review.domain.response.ReviewImageResponse; +import com.swyp.catsgotogedog.review.domain.response.ReviewResponse; +import com.swyp.catsgotogedog.review.repository.ReviewImageRepository; +import com.swyp.catsgotogedog.review.repository.ReviewRecommendHistoryRepository; +import com.swyp.catsgotogedog.review.repository.ReviewRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final ReviewImageRepository reviewImageRepository; + private final UserRepository userRepository; + private final ContentRepository contentRepository; + private final ImageStorageService imageStorageService; + private final ReviewRecommendHistoryRepository reviewRecommendHistoryRepository; + private final ReviewReportService reviewReportService; + + // 리뷰 작성 + @Transactional + public void createReview(int contentId, String userId, CreateReviewRequest request, List images) { + User user = validateUser(userId); + Content content = validateContent(contentId); + + + Review uploadedReview = reviewRepository.save(Review.builder() + .userId(user.getUserId()) + .contentEntity(content) + .score(request.getScore()) + .content(request.getContent()) + .build()); + + if(images != null && !images.isEmpty()) { + if(images.size() > ImageUploadType.REVIEW.getMaxFiles()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_LIMIT_EXCEEDED); + } + uploadAndSaveReviewImages(uploadedReview, images); + } + + } + + // 리뷰 수정 + @Transactional + public void updateReview(int reviewId, String userId, CreateReviewRequest request, List images) { + User user = validateUser(userId); + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + + if(user.getUserId() != review.getUserId()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_FORBIDDEN_ACCESS); + } + + review.setScore(request.getScore()); + review.setContent(request.getContent()); + + if(images != null && !images.isEmpty()) { + List reviewImages = reviewImageRepository.findByReview(review); + int totalImages = reviewImages.size() + images.size(); + + if(totalImages > ImageUploadType.REVIEW.getMaxFiles() || images.size() > ImageUploadType.REVIEW.getMaxFiles()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_LIMIT_EXCEEDED); + } + + List imageInfos = imageStorageService.upload(images, ImageUploadType.REVIEW); + List saveImages = imageInfos.stream() + .map(imageInfo -> ReviewImage.builder() + .review(review) + .imageFilename(imageInfo.key()) + .imageUrl(imageInfo.url()) + .build() + ).toList(); + reviewImageRepository.saveAll(saveImages); + } + } + + // 리뷰 삭제 + @Transactional + public void deleteReview(int reviewId, String userId) { + User user = validateUser(userId); + validateReview(reviewId); + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + + if(user.getUserId() != review.getUserId()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_FORBIDDEN_ACCESS); + } + + List images = reviewImageRepository.findByReview(review); + + images.forEach(image -> imageStorageService.delete(image.getImageFilename())); + + reviewRepository.delete(review); + + } + + // 리뷰 이미지 삭제 + @Transactional + public void deleteReviewImage(int reviewId, int imageId, String userId) { + User user = validateUser(userId); + validateReview(reviewId); + + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + + if(user.getUserId() != review.getUserId()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_FORBIDDEN_ACCESS); + } + + ReviewImage image = reviewImageRepository.findById(imageId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_NOT_FOUND)); + + imageStorageService.delete(image.getImageFilename()); + reviewImageRepository.deleteById(imageId); + } + + // ContentId를 통한 리뷰 목록 조회 + @Transactional(readOnly = true) + public ContentReviewPageResponse fetchReviewsByContentId(int contentId, String sort, Pageable pageable, String userId) { + validateContent(contentId); + + Sort sortObj = createSort(sort); + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortObj); + + // 페이징 리뷰 + Page reviewPage = reviewRepository.findByContentIdWithUserAndReviewImages(contentId, sortedPageable); + List reviews = reviewRepository.findByContentEntityContentId((contentId)); + + List contentReviewImages = reviews.stream() + .flatMap(review -> review.getReviewImages().stream()) + .map(ReviewImageResponse::from) + .toList(); + + Set recommendedReviewIds; + if(userId != null) { + recommendedReviewIds = reviewRecommendHistoryRepository.findRecommendedReviewIdsByUserIdAndReviewIds( + Integer.parseInt(userId), + reviewPage.getContent() + ); + } else { + recommendedReviewIds = new HashSet<>(); + } + + List userIds = reviewPage.getContent().stream() + .map(Review::getUserId) + .distinct() + .collect(Collectors.toList()); + + Map userMap = userRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getUserId, Function.identity())); + + List reviewResponses = reviewPage.getContent().stream() + .map(review -> { + User user = userMap.get(review.getUserId()); + return new ReviewResponse( + review.getContentEntity().getContentId(), + review.getReviewId(), + user != null ? user.getUserId() : 0, + user != null ? user.getDisplayName() : "알 수 없음", + user != null ? user.getImageUrl() : "", + review.getContent(), + review.getScore(), + review.getCreatedAt(), + review.getRecommendedNumber(), + recommendedReviewIds.contains(review.getReviewId()), + reviewReportService.isBlindReview(review.getReviewId()), + review.getReviewImages().stream() + .map(ReviewImageResponse::from) + .collect(Collectors.toList()) + ); + }).toList(); + + return new ContentReviewPageResponse( + (int) reviewPage.getTotalElements(), + reviewPage.getTotalPages(), + reviewPage.getNumber(), + reviewPage.getSize(), + reviewPage.hasNext(), + reviewPage.hasPrevious(), + reviewResponses, + contentReviewImages + ); + } + + // 자신이 작성한 리뷰 목록 페이징 + @Transactional(readOnly = true) + public MyReviewPageResponse fetchReviewsByUserId(String userId, Pageable pageable) { + validateUser(userId); + + Page reviewPage = reviewRepository.findByUserId(Integer.parseInt(userId), pageable); + + List myReviewResponses = reviewPage.getContent().stream() + .map(this::toReviewResponse) + .toList(); + + return new MyReviewPageResponse( + (int) reviewPage.getTotalElements(), + reviewPage.getTotalPages(), + reviewPage.getNumber(), + reviewPage.getSize(), + reviewPage.hasNext(), + reviewPage.hasPrevious(), + myReviewResponses + ); + } + + // 유저의 특정 리뷰 데이터 조회 + @Transactional(readOnly = true) + public MyReviewResponse fetchReviewById(int reviewId, String userId) { + validateUser(userId); + validateReview(reviewId); + + Review review = reviewRepository.findByIdAndUserId(reviewId, userId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_FORBIDDEN_ACCESS)); + + return toReviewResponse(review); + } + + + private User validateUser(String userId) { + return userRepository.findById(Integer.parseInt(userId)) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.MEMBER_NOT_FOUND)); + } + + private Content validateContent(int contentId) { + return contentRepository.findById(contentId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.CONTENT_NOT_FOUND)); + } + + private Review validateReview(int reviewId) { + return reviewRepository.findById(reviewId) + .orElseThrow(() -> new CatsgotogedogException(ErrorCode.REVIEW_NOT_FOUND)); + } + + private void uploadAndSaveReviewImages(Review review, List images) { + if(images.size() > ImageUploadType.REVIEW.getMaxFiles()) { + throw new CatsgotogedogException(ErrorCode.REVIEW_IMAGE_LIMIT_EXCEEDED); + } + + List imageInfos = imageStorageService.upload(images, ImageUploadType.REVIEW); + + List saveImages = imageInfos.stream() + .map(imageInfo -> ReviewImage.builder() + .review(review) + .imageFilename(imageInfo.key()) + .imageUrl(imageInfo.url()) + .build() + ).toList(); + + reviewImageRepository.saveAll(saveImages); + } + + private MyReviewResponse toReviewResponse(Review review) { + return new MyReviewResponse( + review.getContentEntity().getContentId(), + review.getContentEntity().getTitle(), + review.getReviewId(), + review.getContent(), + review.getScore(), + review.getRecommendedNumber(), + review.getCreatedAt(), + review.getReviewImages().stream() + .map(ReviewImageResponse::from) + .toList() + ); + } + + private Sort createSort(String sort) { + return switch(sort) { + case "r" -> Sort.by(Sort.Direction.DESC, "recommendedNumber") + .and(Sort.by(Sort.Direction.DESC, "createdAt")); + case "c" -> Sort.by(Sort.Direction.DESC, "createdAt"); + default -> Sort.by(Sort.Direction.DESC, "recommendedNumber") + .and(Sort.by(Sort.Direction.DESC, "createdAt")); + }; + } + +} diff --git a/src/main/resources/db/migration/mysql/V10__create_content_wish.sql b/src/main/resources/db/migration/mysql/V10__create_content_wish.sql new file mode 100644 index 0000000..58ab249 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V10__create_content_wish.sql @@ -0,0 +1,19 @@ +CREATE TABLE `catsgotogedog`.`content_wish` ( +`wish_id` INT NOT NULL AUTO_INCREMENT, +`user_id` INT NOT NULL, +`content_id` INT NOT NULL, +PRIMARY KEY (`wish_id`), +INDEX `content_wish_user_id_fk_idx` (`user_id` ASC) VISIBLE, +INDEX `content_wish_content_id_fk_idx` (`content_id` ASC) VISIBLE, +UNIQUE INDEX `content_wish_wish_uq` (`user_id` ASC, `content_id` ASC) VISIBLE, +CONSTRAINT `content_wish_user_id_fk` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION, +CONSTRAINT `content_wish_content_id_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION) +COMMENT = '장소 찜 목록'; diff --git a/src/main/resources/db/migration/mysql/V11__insert_review_report_reson.sql b/src/main/resources/db/migration/mysql/V11__insert_review_report_reson.sql new file mode 100644 index 0000000..07d8c79 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V11__insert_review_report_reson.sql @@ -0,0 +1,24 @@ +ALTER TABLE `catsgotogedog`.`review_report` +DROP FOREIGN KEY `review_report_review_id`; +ALTER TABLE `catsgotogedog`.`review_report` + ADD INDEX `review_report_user_id_idx` (`user_id` ASC) INVISIBLE, +ADD UNIQUE INDEX `user_id_review_id_uq` (`user_id` ASC, `review_id` ASC) INVISIBLE; +; +ALTER TABLE `catsgotogedog`.`review_report` + ADD CONSTRAINT `review_report_review_id` + FOREIGN KEY (`review_id`) + REFERENCES `catsgotogedog`.`content_review` (`review_id`) + ON DELETE CASCADE, +ADD CONSTRAINT `review_report_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION; + + + +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('1', '욕설 및 비속어 사용'); +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('2', '개인정보 노출'); +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('3', '광고 및 홍보성 댓글'); +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('4', '도배성 댓글'); +INSERT INTO `catsgotogedog`.`report_reason` (`reason_id`, `content`) VALUES ('5', '부적절하거나 불쾌한 내용'); diff --git a/src/main/resources/db/migration/mysql/V12__column_type_extend.sql b/src/main/resources/db/migration/mysql/V12__column_type_extend.sql new file mode 100644 index 0000000..0c213e4 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V12__column_type_extend.sql @@ -0,0 +1,33 @@ +ALTER TABLE `catsgotogedog`.`sights_information` + CHANGE COLUMN `parking` `parking` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `rest_date` `rest_date` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `use_season` `use_season` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `use_time` `use_time` TEXT NULL DEFAULT NULL, + CHANGE COLUMN `exp_age_range` `exp_age_range` TEXT NULL DEFAULT NULL; + +ALTER TABLE `catsgotogedog`.`restaurant_information` + CHANGE COLUMN `open_date` `open_date` DATE NULL DEFAULT NULL , + CHANGE COLUMN `open_time` `open_time` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `takeout` `takeout` VARCHAR(50) NULL DEFAULT NULL , + CHANGE COLUMN `reservation` `reservation` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `rest_date` `rest_date` VARCHAR(200) NULL DEFAULT NULL ; + +ALTER TABLE `catsgotogedog`.`restaurant_information` + CHANGE COLUMN `smoking` `smoking` VARCHAR(100) NULL DEFAULT NULL, + CHANGE COLUMN `treat_menu` `treat_menu` VARCHAR(200) NULL DEFAULT NULL, + CHANGE COLUMN `seat` `seat` VARCHAR(100) NULL DEFAULT NULL; + +ALTER TABLE `catsgotogedog`.`lodge_information` + CHANGE COLUMN `pickup_service` `pickup_service` VARCHAR(100) NULL DEFAULT NULL , + CHANGE COLUMN `reservation_url` `reservation_url` VARCHAR(300) NULL DEFAULT NULL, + CHANGE COLUMN `room_type` `room_type` VARCHAR(100) NULL DEFAULT NULL, + CHANGE COLUMN `scale` `scale` TEXT NULL DEFAULT NULL , + CHANGE COLUMN `sub_facility` `sub_facility` VARCHAR(200) NULL DEFAULT NULL, + CHANGE COLUMN `refund_regulation` `refund_regulation` TEXT NULL DEFAULT NULL, + CHANGE COLUMN `cooking` `cooking` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `foodplace` `foodplace` VARCHAR(200) NULL DEFAULT NULL, + CHANGE COLUMN `check_in_time` `check_in_time` VARCHAR(100) NULL DEFAULT NULL , + CHANGE COLUMN `check_out_time` `check_out_time` VARCHAR(100) NULL DEFAULT NULL, + CHANGE COLUMN `information` `information` VARCHAR(300) NULL DEFAULT NULL , + CHANGE COLUMN `parking` `parking` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `reservation_info` `reservation_info` VARCHAR(1000) NULL DEFAULT NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/mysql/V13__add_pet_type.sql b/src/main/resources/db/migration/mysql/V13__add_pet_type.sql new file mode 100644 index 0000000..8fdea0a --- /dev/null +++ b/src/main/resources/db/migration/mysql/V13__add_pet_type.sql @@ -0,0 +1 @@ +ALTER TABLE `catsgotogedog`.`pet` ADD COLUMN `type` VARCHAR(50) NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/mysql/V14__alter_hashtag_autoincreament.sql b/src/main/resources/db/migration/mysql/V14__alter_hashtag_autoincreament.sql new file mode 100644 index 0000000..67fd937 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V14__alter_hashtag_autoincreament.sql @@ -0,0 +1,2 @@ +ALTER TABLE `catsgotogedog`.`hashtag` + CHANGE COLUMN `hashtag_id` `hashtag_id` INT NOT NULL AUTO_INCREMENT ; diff --git a/src/main/resources/db/migration/mysql/V15__create_ai_recommends_table.sql b/src/main/resources/db/migration/mysql/V15__create_ai_recommends_table.sql new file mode 100644 index 0000000..ef04351 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V15__create_ai_recommends_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE `catsgotogedog`.`ai_recommends` ( + `recommends_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `message` TEXT NULL, + `image_url` VARCHAR(255) NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`recommends_id`), + CONSTRAINT `ai_recommends_content_id_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION +); diff --git a/src/main/resources/db/migration/mysql/V16__last_view_history_uq.sql b/src/main/resources/db/migration/mysql/V16__last_view_history_uq.sql new file mode 100644 index 0000000..ff6bc4e --- /dev/null +++ b/src/main/resources/db/migration/mysql/V16__last_view_history_uq.sql @@ -0,0 +1,3 @@ +ALTER TABLE `catsgotogedog`.`last_view_history` + ADD UNIQUE INDEX `last_view_content_id_user_id` (`user_id` ASC, `content_id` ASC) VISIBLE; +; diff --git a/src/main/resources/db/migration/mysql/V17__drop_email_unique_index.sql b/src/main/resources/db/migration/mysql/V17__drop_email_unique_index.sql new file mode 100644 index 0000000..f4cdc26 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V17__drop_email_unique_index.sql @@ -0,0 +1 @@ +ALTER TABLE `catsgotogedog`.`user` DROP INDEX `email_UNIQUE`; \ No newline at end of file diff --git a/src/main/resources/db/migration/mysql/V1__init.sql b/src/main/resources/db/migration/mysql/V1__init.sql new file mode 100644 index 0000000..2dc4ed7 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V1__init.sql @@ -0,0 +1,646 @@ +-- MySQL Workbench Forward Engineering + +SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; +SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; +SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; + +-- ----------------------------------------------------- +-- Schema mydb +-- ----------------------------------------------------- +-- ----------------------------------------------------- +-- Schema catsgotogedog +-- ----------------------------------------------------- + +-- ----------------------------------------------------- +-- Schema catsgotogedog +-- ----------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS `catsgotogedog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci ; +USE `catsgotogedog` ; + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`category_code` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`category_code` ( + `category_id` INT NOT NULL AUTO_INCREMENT, + `category_name` VARCHAR(50) NOT NULL, + PRIMARY KEY (`category_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '숙박, 음식점, 관광지 등 카테고리 분류를 위한 테이블'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`region_code` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`region_code` ( + `region_id` INT NOT NULL AUTO_INCREMENT, + `region_name` VARCHAR(50) NOT NULL, + `parent_code` INT NULL DEFAULT NULL, + PRIMARY KEY (`region_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '지역코드 구분 테이블'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`content` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`content` ( + `content_id` INT NOT NULL AUTO_INCREMENT, + `category_id` INT NOT NULL, + `region_id` INT NOT NULL, + `addr1` VARCHAR(100) NULL DEFAULT NULL, + `addr2` VARCHAR(100) NULL DEFAULT NULL, + `image` VARCHAR(255) NULL DEFAULT NULL, + `thumb_image` VARCHAR(255) NULL DEFAULT NULL, + `copyright` VARCHAR(10) NULL DEFAULT NULL, + `mapx` DECIMAL(10,8) NULL DEFAULT NULL, + `mapy` DECIMAL(11,8) NULL DEFAULT NULL, + `mlevel` SMALLINT NULL DEFAULT NULL, + `modified_at` DATETIME NULL DEFAULT NULL, + `tel` VARCHAR(20) NULL DEFAULT NULL, + `title` VARCHAR(255) NULL DEFAULT NULL, + `zipcode` INT NULL DEFAULT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `content_type_id` INT NOT NULL, + PRIMARY KEY (`content_id`), + INDEX `category_code_category_code_idx` (`category_id` ASC) VISIBLE, + INDEX `region_code_region_id_idx` (`region_id` ASC) VISIBLE, + CONSTRAINT `category_code_category_id` + FOREIGN KEY (`category_id`) + REFERENCES `catsgotogedog`.`category_code` (`category_id`), + CONSTRAINT `region_code_region_id` + FOREIGN KEY (`region_id`) + REFERENCES `catsgotogedog`.`region_code` (`region_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소 목록 테이블'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`content_image` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`content_image` ( + `content_image_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `image_url` VARCHAR(255) NULL DEFAULT NULL, + `image_filename` VARCHAR(255) NULL DEFAULT NULL, + `small_image_url` VARCHAR(255) NULL DEFAULT NULL, + `small_image_filename` VARCHAR(255) NULL DEFAULT NULL, + PRIMARY KEY (`content_image_id`), + INDEX `content_content_id_content_image_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_content_image_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소별 사진 테이블'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`user` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`user` ( + `user_id` INT NOT NULL AUTO_INCREMENT, + `display_name` VARCHAR(50) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `provider` VARCHAR(50) NOT NULL, + `provider_id` VARCHAR(255) NOT NULL, + `image_filename` VARCHAR(255) NOT NULL, + `image_url` VARCHAR(255) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_active` TINYINT NULL DEFAULT '1', + PRIMARY KEY (`user_id`), + UNIQUE INDEX `display_name_UNIQUE` (`display_name` ASC) VISIBLE, + UNIQUE INDEX `email_UNIQUE` (`email` ASC) VISIBLE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '유저 정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`content_review` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`content_review` ( + `review_id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `content_id` INT NOT NULL, + `content` VARCHAR(6000) NOT NULL, + `score` DECIMAL(2,1) NOT NULL, + `like` INT NULL DEFAULT '0', + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`review_id`), + INDEX `review_user_id_idx` (`user_id` ASC) VISIBLE, + INDEX `review_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `review_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`), + CONSTRAINT `review_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소 리뷰'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`festival_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`festival_information` ( + `festival_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `age_limit` VARCHAR(45) NULL DEFAULT NULL, + `booking_place` VARCHAR(50) NULL DEFAULT NULL, + `discount_info` VARCHAR(100) NULL DEFAULT NULL, + `event_start_date` DATE NULL DEFAULT NULL, + `event_end_date` DATE NULL DEFAULT NULL, + `event_homepage` VARCHAR(255) NULL DEFAULT NULL, + `event_place` VARCHAR(100) NULL DEFAULT NULL, + `place_info` VARCHAR(50) NULL DEFAULT NULL, + `play_time` VARCHAR(50) NULL DEFAULT NULL, + `program` VARCHAR(100) NULL DEFAULT NULL, + `spend_time` VARCHAR(50) NULL DEFAULT NULL, + `organizer` VARCHAR(50) NULL DEFAULT NULL, + `organizer_tel` VARCHAR(50) NULL DEFAULT NULL, + `supervisor` VARCHAR(45) NULL DEFAULT NULL, + `sub_event` VARCHAR(100) NULL DEFAULT NULL, + `fee_info` VARCHAR(100) NULL DEFAULT NULL, + PRIMARY KEY (`festival_id`), + INDEX `content_content_id_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_festival_information_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '소개정보조회_행사_공연_축제'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`hashtag` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`hashtag` ( + `hashtag_id` INT NOT NULL, + `content_id` INT NOT NULL, + `content` VARCHAR(50) NOT NULL, + PRIMARY KEY (`hashtag_id`), + INDEX `hashtag_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `hashtag_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '해시태그'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`last_view_history` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`last_view_history` ( + `user_id` INT NOT NULL, + `content_id` INT NOT NULL, + `last_viewed_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `content_id`), + INDEX `last_view_history_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `last_view_history_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`), + CONSTRAINT `last_view_history_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '최근 본 장소'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`lodge_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`lodge_information` ( + `lodge_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `capacity_count` INT NULL DEFAULT NULL, + `lodge_informationcol` INT NULL DEFAULT NULL, + `benikia` TINYINT NULL DEFAULT NULL, + `check_in_time` TIME NULL DEFAULT NULL, + `check_out_time` TIME NULL DEFAULT NULL, + `cooking` VARCHAR(50) NULL DEFAULT NULL, + `foodplace` VARCHAR(50) NULL DEFAULT NULL, + `hanok` TINYINT NULL DEFAULT NULL, + `information` VARCHAR(50) NULL DEFAULT NULL, + `parking` VARCHAR(50) NULL DEFAULT NULL, + `pickup_service` TINYINT NULL DEFAULT NULL, + `room_count` INT NULL DEFAULT NULL, + `reservation_info` VARCHAR(30) NULL DEFAULT NULL, + `reservation_url` VARCHAR(50) NULL DEFAULT NULL, + `room_type` VARCHAR(30) NULL DEFAULT NULL, + `scale` VARCHAR(30) NULL DEFAULT NULL, + `sub_facility` VARCHAR(50) NULL DEFAULT NULL, + `barbecue` TINYINT NULL DEFAULT NULL, + `beauty` TINYINT NULL DEFAULT NULL, + `beverage` TINYINT NULL DEFAULT NULL, + `bicycle` TINYINT NULL DEFAULT NULL, + `campfire` TINYINT NULL DEFAULT NULL, + `fitness` TINYINT NULL DEFAULT NULL, + `karaoke` TINYINT NULL DEFAULT NULL, + `public_bath` TINYINT NULL DEFAULT NULL, + `public_pc_room` TINYINT NULL DEFAULT NULL, + `sauna` TINYINT NULL DEFAULT NULL, + `seminar` TINYINT NULL DEFAULT NULL, + `sports` TINYINT NULL DEFAULT NULL, + `refund_regulation` VARCHAR(100) NULL DEFAULT NULL, + PRIMARY KEY (`lodge_id`), + INDEX `content_content_id_lodge_information_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_lodge_information_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '소개정보조회_숙박'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`pet_size` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`pet_size` ( + `size_id` INT NOT NULL AUTO_INCREMENT, + `size` VARCHAR(10) NOT NULL, + `size_tooltip` VARCHAR(100) NOT NULL, + PRIMARY KEY (`size_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '반려동물 사이즈 정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`pet` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`pet` ( + `pet_id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `gender` CHAR(1) NOT NULL, + `birth` DATE NOT NULL, + `fierce_dog` TINYINT NOT NULL, + `size_id` INT NOT NULL, + `name` VARCHAR(20) NOT NULL, + `image_filename` VARCHAR(255) NULL DEFAULT NULL, + `image_url` VARCHAR(255) NULL DEFAULT NULL, + PRIMARY KEY (`pet_id`), + INDEX `user_id_idx` (`user_id` ASC) VISIBLE, + INDEX `size_id_idx` (`size_id` ASC) VISIBLE, + CONSTRAINT `pet_pet_size_id` + FOREIGN KEY (`size_id`) + REFERENCES `catsgotogedog`.`pet_size` (`size_id`), + CONSTRAINT `pet_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '반려동물 정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`pet_guide` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`pet_guide` ( + `pet_guide_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `accident_prep` VARCHAR(50) NULL DEFAULT NULL, + `available_facility` VARCHAR(50) NULL DEFAULT NULL, + `provided_item` VARCHAR(50) NULL DEFAULT NULL, + `etc_info` VARCHAR(255) NULL DEFAULT NULL, + `purchasable_item` VARCHAR(50) NULL DEFAULT NULL, + `allowed_pet_type` VARCHAR(50) NULL DEFAULT NULL, + `rent_item` VARCHAR(50) NULL DEFAULT NULL, + `pet_prep` VARCHAR(50) NULL DEFAULT NULL, + `with_pet` VARCHAR(50) NULL DEFAULT NULL, + PRIMARY KEY (`pet_guide_id`), + INDEX `content_content_id_pet_guide_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_pet_guide_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소별 반려동물 동반시 안내사항'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`recur_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`recur_information` ( + `recur_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `info_name` VARCHAR(45) NULL DEFAULT NULL, + `info_text` TEXT NULL DEFAULT NULL, + PRIMARY KEY (`recur_id`), + INDEX `content_content_id_recur_information_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_recur_information_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소별 반복정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`recur_information_room` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`recur_information_room` ( + `recur_room_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `room_title` VARCHAR(100) NULL DEFAULT NULL, + `room_size1` INT NULL DEFAULT NULL, + `room_count` INT NULL DEFAULT NULL, + `room_base_couint` INT NULL DEFAULT NULL, + `room_max_count` INT NULL DEFAULT NULL, + `off_season_week_min_fee` INT NULL DEFAULT NULL, + `off_season_weekend_min_fee` INT NULL DEFAULT NULL, + `peak_season_week_min_fee` INT NULL DEFAULT NULL, + `peak_season_weekend_min_fee` INT NULL DEFAULT NULL, + `room_intro` TEXT NULL DEFAULT NULL, + `room_bath_pacility` TINYINT NULL DEFAULT NULL, + `room_bath` TINYINT NULL DEFAULT NULL, + `room_home_theater` TINYINT NULL DEFAULT NULL, + `room_aircondition` TINYINT NULL DEFAULT NULL, + `room_tv` TINYINT NULL DEFAULT NULL, + `room_pc` TINYINT NULL DEFAULT NULL, + `room_cable` TINYINT NULL DEFAULT NULL, + `room_internet` TINYINT NULL DEFAULT NULL, + `room_refrigerator` TINYINT NULL DEFAULT NULL, + `room_toiletries` TINYINT NULL DEFAULT NULL, + `room_sofa` TINYINT NULL DEFAULT NULL, + `room_cook` TINYINT NULL DEFAULT NULL, + `room_table` TINYINT NULL DEFAULT NULL, + `room_hairdryer` TINYINT NULL DEFAULT NULL, + `room_size2` DECIMAL(10,2) NULL DEFAULT NULL, + PRIMARY KEY (`recur_room_id`), + INDEX `content_content_id_recur_information_room_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_recur_information_room_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '숙박 객실별 반복정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`recur_information_room_image` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`recur_information_room_image` ( + `image_id` INT NOT NULL AUTO_INCREMENT, + `recur_room_id` INT NOT NULL, + `image_url` VARCHAR(255) NULL DEFAULT NULL, + `image_filename` VARCHAR(255) NULL DEFAULT NULL, + `image_alt` VARCHAR(255) NULL DEFAULT NULL, + `image_copyright` VARCHAR(50) NULL DEFAULT NULL, + PRIMARY KEY (`image_id`), + INDEX `recur_information_room_recur_information_room_image_fk_idx` (`recur_room_id` ASC) VISIBLE, + CONSTRAINT `recur_information_room_recur_information_room_image_fk` + FOREIGN KEY (`recur_room_id`) + REFERENCES `catsgotogedog`.`recur_information_room` (`recur_room_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '반복정보 객실별 이미지'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`refresh_token` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`refresh_token` ( + `user_id` INT NOT NULL, + `refresh_token` TEXT NOT NULL, + `expires_at` DATETIME NOT NULL, + `is_revoked` TINYINT NOT NULL DEFAULT '0', + PRIMARY KEY (`user_id`), + CONSTRAINT `fk_token_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = 'JWT 리프레시 토큰 정보'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`report_reason` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`report_reason` ( + `reason_id` INT NOT NULL AUTO_INCREMENT, + `content` VARCHAR(255) NOT NULL, + PRIMARY KEY (`reason_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '신고 사유 목록'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`restaurant_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`restaurant_information` ( + `restaurant_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `chk_creditcard` VARCHAR(50) NULL DEFAULT NULL, + `discount_info` VARCHAR(100) NULL DEFAULT NULL, + `signature_menu` VARCHAR(100) NULL DEFAULT NULL, + `information` VARCHAR(100) NULL DEFAULT NULL, + `kids_facility` TINYINT NULL DEFAULT NULL, + `open_date` DATE NULL DEFAULT NULL, + `open_time` VARCHAR(50) NULL DEFAULT NULL, + `takeout` VARCHAR(10) NULL DEFAULT NULL, + `parking` VARCHAR(100) NULL DEFAULT NULL, + `reservation` VARCHAR(100) NULL DEFAULT NULL, + `rest_date` VARCHAR(50) NULL DEFAULT NULL, + `scale` INT NULL DEFAULT NULL, + `seat` INT NULL DEFAULT NULL, + `smoking` TINYINT NULL DEFAULT NULL, + `treat_menu` VARCHAR(100) NULL DEFAULT NULL, + PRIMARY KEY (`restaurant_id`), + INDEX `content_content_id_restaurant_information_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_restaurant_information_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '소개정보조회_음식점'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`review_image` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`review_image` ( + `image_id` INT NOT NULL AUTO_INCREMENT, + `review_id` INT NOT NULL, + `image_filename` VARCHAR(255) NOT NULL, + `image_url` VARCHAR(255) NOT NULL, + PRIMARY KEY (`image_id`), + INDEX `review_image_review_id_idx` (`review_id` ASC) VISIBLE, + CONSTRAINT `review_image_review_id` + FOREIGN KEY (`review_id`) + REFERENCES `catsgotogedog`.`content_review` (`review_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '리뷰 이미지'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`review_report` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`review_report` ( + `report_id` INT NOT NULL AUTO_INCREMENT, + `review_id` INT NOT NULL, + `reason_id` INT NOT NULL, + `user_id` INT NOT NULL, + PRIMARY KEY (`report_id`), + INDEX `review_report_review_id_idx` (`review_id` ASC) VISIBLE, + INDEX `review_report_reason_id_idx` (`reason_id` ASC) VISIBLE, + CONSTRAINT `review_report_reason_id` + FOREIGN KEY (`reason_id`) + REFERENCES `catsgotogedog`.`report_reason` (`reason_id`), + CONSTRAINT `review_report_review_id` + FOREIGN KEY (`review_id`) + REFERENCES `catsgotogedog`.`content_review` (`review_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '리뷰 신고'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`sigtes_information` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`sigtes_information` ( + `sights_id` INT NOT NULL AUTO_INCREMENT, + `content_id` INT NOT NULL, + `content_type_id` INT NULL DEFAULT NULL, + `accom_count` INT NULL DEFAULT NULL, + `chk_creditcard` VARCHAR(50) NULL DEFAULT NULL, + `exp_age_range` VARCHAR(45) NULL DEFAULT NULL, + `exp_guide` VARCHAR(500) NULL DEFAULT NULL, + `info_center` VARCHAR(100) NULL DEFAULT NULL, + `open_date` DATE NULL DEFAULT NULL, + `parking` VARCHAR(50) NULL DEFAULT NULL, + `rest_date` VARCHAR(50) NULL DEFAULT NULL, + `use_season` VARCHAR(50) NULL DEFAULT NULL, + `use_time` VARCHAR(50) NULL DEFAULT NULL, + `heritage1` TINYINT NULL DEFAULT NULL, + `heritage2` TINYINT NULL DEFAULT NULL, + `heritage3` TINYINT NULL DEFAULT NULL, + PRIMARY KEY (`sights_id`), + INDEX `content_content_id_fk_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `content_content_id_fk` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`) + ON DELETE CASCADE) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '소개정보조회_관광지'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`ticket` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`ticket` ( + `ticket_id` INT NOT NULL AUTO_INCREMENT, + `email` VARCHAR(255) NOT NULL, + `content` TEXT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ticket_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '문의 내역'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`view_log` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`view_log` ( + `view_id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `content_id` INT NOT NULL, + `viewed_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`view_id`, `user_id`, `content_id`), + INDEX `view_log_user_id_idx` (`user_id` ASC) VISIBLE, + INDEX `view_log_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `view_log_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`), + CONSTRAINT `view_log_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '조회 정보 기록 저장'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`view_total` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`view_total` ( + `content_id` INT NOT NULL, + `total_view` INT NULL DEFAULT '0', + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`content_id`), + CONSTRAINT `view_total_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '장소별 전체 조회수'; + + +-- ----------------------------------------------------- +-- Table `catsgotogedog`.`visit_history` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `catsgotogedog`.`visit_history` ( + `visit_id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `content_id` INT NOT NULL, + PRIMARY KEY (`visit_id`), + INDEX `visit_history_user_id_idx` (`user_id` ASC) VISIBLE, + INDEX `visit_history_content_id_idx` (`content_id` ASC) VISIBLE, + CONSTRAINT `visit_history_content_id` + FOREIGN KEY (`content_id`) + REFERENCES `catsgotogedog`.`content` (`content_id`), + CONSTRAINT `visit_history_user_id` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci + COMMENT = '방문한 장소'; + + +SET SQL_MODE=@OLD_SQL_MODE; +SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; +SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; diff --git a/src/main/resources/db/migration/mysql/V2__remove_user_imagefilename_notnull.sql b/src/main/resources/db/migration/mysql/V2__remove_user_imagefilename_notnull.sql new file mode 100644 index 0000000..0e27103 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V2__remove_user_imagefilename_notnull.sql @@ -0,0 +1,2 @@ +ALTER TABLE `catsgotogedog`.`user` +CHANGE COLUMN `image_filename` `image_filename` VARCHAR(255) NULL ; diff --git a/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql b/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql new file mode 100644 index 0000000..02c05df --- /dev/null +++ b/src/main/resources/db/migration/mysql/V3__alter_categaory_content.sql @@ -0,0 +1,20 @@ +ALTER TABLE `catsgotogedog`.`content` +DROP FOREIGN KEY `category_code_category_id`; + +ALTER TABLE `catsgotogedog`.`category_code` + CHANGE COLUMN `category_id` `category_id` VARCHAR(30) NOT NULL ; + +ALTER TABLE `catsgotogedog`.`content` + CHANGE COLUMN `category_id` `category_id` VARCHAR(30) NOT NULL ; + +ALTER TABLE `catsgotogedog`.`content` + ADD CONSTRAINT `category_code_category_id` + FOREIGN KEY (`category_id`) + REFERENCES `catsgotogedog`.`category_code` (`category_id`); + +ALTER TABLE `catsgotogedog`.`content` + CHANGE COLUMN `mapx` `mapx` DECIMAL(13,10) NULL DEFAULT NULL , + CHANGE COLUMN `mapy` `mapy` DECIMAL(13,10) NULL DEFAULT NULL ; + +ALTER TABLE `catsgotogedog`.`content` + ADD COLUMN `overview` TEXT NULL; diff --git a/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql b/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql new file mode 100644 index 0000000..aeee765 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V4__alter_region_code_content.sql @@ -0,0 +1,20 @@ +ALTER TABLE `catsgotogedog`.`content` +DROP FOREIGN KEY `region_code_region_id`; + +ALTER TABLE `catsgotogedog`.`category_code` + ADD COLUMN `content_type_id` INT NULL AFTER `category_name`; + +ALTER TABLE `catsgotogedog`.`content` +DROP COLUMN `region_id`, +ADD COLUMN `contentcol` VARCHAR(45) NULL AFTER `content_type_id`, +ADD COLUMN `sido_code` INT NULL AFTER `contentcol`, +ADD COLUMN `sigungu_code` INT NULL AFTER `sido_code`, +DROP INDEX `region_code_region_id_idx`; + +ALTER TABLE `catsgotogedog`.`region_code` + ADD COLUMN `sido_code` INT NULL AFTER `region_name`, +ADD COLUMN `sigungu_code` INT NULL AFTER `sido_code`, +ADD UNIQUE INDEX `sido_sigungu_code_UNIQUE` (`sido_code` ASC, `sigungu_code` ASC) VISIBLE; + +ALTER TABLE `catsgotogedog`.`region_code` + ADD COLUMN `region_level` INT NULL AFTER `parent_code`; \ No newline at end of file diff --git a/src/main/resources/db/migration/mysql/V5__init_region_data.sql b/src/main/resources/db/migration/mysql/V5__init_region_data.sql new file mode 100644 index 0000000..ddd39ef --- /dev/null +++ b/src/main/resources/db/migration/mysql/V5__init_region_data.sql @@ -0,0 +1,256 @@ +ALTER TABLE `catsgotogedog`.`sigtes_information` + RENAME TO `catsgotogedog`.`sights_information` ; +ALTER TABLE `catsgotogedog`.`pet_guide` + CHANGE COLUMN `allowed_pet_type` `allowed_pet_type` VARCHAR(255) NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`pet_guide` + CHANGE COLUMN `etc_info` `etc_info` TEXT NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`recur_information_room` + CHANGE COLUMN `room_bath_pacility` `room_bath_facility` TINYINT NULL DEFAULT NULL ; +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('1', '서울', '1', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('2', '인천', '2', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('3', '대전', '3', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('4', '대구', '4', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('5', '광주', '5', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('6', '부산', '6', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('7', '울산', '7', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('8', '세종특별자치시', '8', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('9', '경기도', '31', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('10', '강원특별자치도', '32', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('11', '충청북도', '33', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('12', '충청남도', '34', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('13', '경상북도', '35', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('14', '경상남도', '36', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('15', '전북특별자치도', '37', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('16', '전라남도', '38', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sido_code`, `region_level`) VALUES ('17', '제주도', '39', '1'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('18', '강남구', '1', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('19', '강동구', '2', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('20', '강북구', '3', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('21', '강서구', '4', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('22', '관악구', '5', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('23', '광진구', '6', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('24', '구로구', '7', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('25', '금천구', '8', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('26', '노원구', '9', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('27', '도봉구', '10', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('28', '동대문구', '11', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('29', '동작구', '12', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('30', '마포구', '13', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('31', '서대문구', '14', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('32', '서초구', '15', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('33', '성동구', '16', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('34', '성북구', '17', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('35', '송파구', '18', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('36', '양천구', '19', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('37', '영등포구', '20', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('38', '용산구', '21', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('39', '은평구', '22', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('40', '종로구', '23', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('41', '중구', '24', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('42', '중랑구', '25', '1', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('43', '강화군', '1', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('44', '계양구', '2', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('45', '미추홀구', '3', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('46', '남동구', '4', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('47', '동구', '5', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('48', '부평구', '6', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('49', '서구', '7', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('50', '연수구', '8', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('51', '옹진군', '9', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('52', '중구', '10', '2', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('53', '대덕구', '1', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('54', '동구', '2', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('55', '서구', '3', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('56', '유성구', '4', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('57', '중구', '5', '3', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('58', '남구', '1', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('59', '달서구', '2', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('60', '달성군', '3', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('61', '동구', '4', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('62', '북구', '5', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('63', '서구', '6', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('64', '수성구', '7', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('65', '중구', '8', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('66', '군위군', '9', '4', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('67', '광산구', '1', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('68', '남구', '2', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('69', '동구', '3', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('70', '북구', '4', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('71', '서구', '5', '5', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('72', '강서구', '1', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('73', '금정구', '2', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('74', '기장군', '3', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('75', '남구', '4', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('76', '동구', '5', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('77', '동래구', '6', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('78', '부산진구', '7', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('79', '북구', '8', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('80', '사상구', '9', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('81', '사하구', '10', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('82', '서구', '11', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('83', '수영구', '12', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('84', '연제구', '13', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('85', '영도구', '14', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('86', '중구', '15', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('87', '해운대구', '16', '6', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('88', '중구', '1', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('89', '남구', '2', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('90', '동구', '3', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('91', '북구', '4', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('92', '울주군', '5', '7', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('93', '세종특별자치시', '1', '8', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('94', '가평군', '1', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('95', '고양시', '2', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('96', '과천시', '3', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('97', '광명시', '4', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('98', '광주시', '5', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('99', '구리시', '6', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('100', '군포시', '7', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('101', '김포시', '8', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('102', '남양주시', '9', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('103', '동두천시', '10', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('104', '부천시', '11', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('105', '성남시', '12', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('106', '수원시', '13', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('107', '시흥시', '14', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('108', '안산시', '15', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('109', '안성시', '16', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('110', '안양시', '17', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('111', '양주시', '18', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('112', '양평군', '19', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('113', '여주시', '20', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('114', '연천군', '21', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('115', '오산시', '22', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('116', '용인시', '23', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('117', '의왕시', '24', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('118', '의정부시', '25', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('119', '이천시', '26', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('120', '파주시', '27', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('121', '평택시', '28', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('122', '포천시', '29', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('123', '하남시', '30', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('124', '화성시', '31', '31', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('125', '강릉시', '1', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('126', '고성군', '2', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('127', '동해시', '3', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('128', '삼척시', '4', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('129', '속초시', '5', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('130', '양구군', '6', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('131', '양양군', '7', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('132', '영월군', '8', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('133', '원주시', '9', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('134', '인제군', '10', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('135', '정선군', '11', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('136', '철원군', '12', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('137', '춘천시', '13', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('138', '태백시', '14', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('139', '평창군', '15', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('140', '홍천군', '16', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('141', '화천군', '17', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('142', '횡성군', '18', '32', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('143', '괴산군', '1', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('144', '단양군', '2', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('145', '보은군', '3', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('146', '영동군', '4', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('147', '옥천군', '5', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('148', '음성군', '6', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('149', '제천시', '7', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('150', '진천군', '8', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('151', '청주시', '10', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('152', '충주시', '11', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('153', '증평군', '12', '33', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('154', '공주시', '1', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('155', '금산군', '2', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('156', '논산시', '3', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('157', '당진시', '4', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('158', '보령시', '5', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('159', '부여군', '6', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('160', '서산시', '7', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('161', '서천군', '8', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('162', '아산시', '9', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('163', '예산군', '11', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('164', '천안시', '12', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('165', '청양군', '13', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('166', '태안군', '14', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('167', '홍성군', '15', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('168', '계룡시', '16', '34', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('169', '경산시', '1', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('170', '경주시', '2', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('171', '고령군', '3', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('172', '구미시', '4', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('173', '김천시', '6', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('174', '문경시', '7', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('175', '봉화군', '8', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('176', '상주시', '9', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('177', '성주군', '10', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('178', '안동시', '11', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('179', '영덕군', '12', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('180', '영양군', '13', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('181', '영주시', '14', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('182', '영천시', '15', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('183', '예천군', '16', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('184', '울릉군', '17', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('185', '울진군', '18', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('186', '의성군', '19', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('187', '청도군', '20', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('188', '청송군', '21', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('189', '칠곡군', '22', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('190', '포항시', '23', '35', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('191', '거제시', '1', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('192', '거창군', '2', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('193', '고성군', '3', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('194', '김해시', '4', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('195', '남해군', '5', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('196', '밀양시', '7', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('197', '사천시', '8', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('198', '산청군', '9', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('199', '양산시', '10', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('200', '의령군', '12', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('201', '진주시', '13', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('202', '창녕군', '15', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('203', '창원시', '16', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('204', '통영시', '17', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('205', '하동군', '18', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('206', '함안군', '19', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('207', '함양군', '20', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('208', '합천군', '21', '36', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('209', '고창군', '1', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('210', '군산시', '2', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('211', '김제시', '3', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('212', '남원시', '4', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('213', '무주군', '5', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('214', '부안군', '6', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('215', '순창군', '7', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('216', '완주군', '8', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('217', '익산시', '9', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('218', '임실군', '10', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('219', '장수군', '11', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('220', '전주시', '12', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('221', '정읍시', '13', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('222', '진안군', '14', '37', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('223', '강진군', '1', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('224', '고흥군', '2', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('225', '곡성군', '3', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('226', '광양시', '4', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('227', '구례군', '5', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('228', '나주시', '6', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('229', '담양군', '7', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('230', '목포시', '8', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('231', '무안군', '9', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('232', '보성군', '10', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('233', '순천시', '11', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('234', '신안군', '12', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('235', '여수시', '13', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('236', '영광군', '16', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('237', '영암군', '17', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('238', '완도군', '18', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('239', '장성군', '19', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('240', '장흥군', '20', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('241', '진도군', '21', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('242', '함평군', '22', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('243', '해남군', '23', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('244', '화순군', '24', '38', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('245', '남제주군', '1', '39', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('246', '북제주군', '2', '39', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('247', '서귀포시', '3', '39', '2'); +INSERT INTO `catsgotogedog`.`region_code` (`region_id`, `region_name`, `sigungu_code`, `parent_code`, `region_level`) VALUES ('248', '제주시', '4', '39', '2'); \ No newline at end of file diff --git a/src/main/resources/db/migration/mysql/V6__table.sql b/src/main/resources/db/migration/mysql/V6__table.sql new file mode 100644 index 0000000..ab34206 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V6__table.sql @@ -0,0 +1,13 @@ +ALTER TABLE `catsgotogedog`.`recur_information_room` + CHANGE COLUMN `room_base_couint` `room_base_count` INT NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`festival_information` + ADD COLUMN `supervisor_tel` VARCHAR(50) NULL AFTER `supervisor`; +ALTER TABLE `catsgotogedog`.`lodge_information` +DROP COLUMN `lodge_informationcol`, +ADD COLUMN `goodstay` TINYINT NULL AFTER `capacity_count`; +ALTER TABLE `catsgotogedog`.`content` + CHANGE COLUMN `tel` `tel` VARCHAR(50) NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`pet_guide` + CHANGE COLUMN `available_facility` `available_facility` VARCHAR(250) NULL DEFAULT NULL ; +ALTER TABLE `catsgotogedog`.`restaurant_information` + CHANGE COLUMN `open_date` `open_date` VARCHAR(100) NULL DEFAULT NULL ; diff --git a/src/main/resources/db/migration/mysql/V7__create_review_recommend_history.sql b/src/main/resources/db/migration/mysql/V7__create_review_recommend_history.sql new file mode 100644 index 0000000..35e7101 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V7__create_review_recommend_history.sql @@ -0,0 +1,25 @@ +CREATE TABLE `catsgotogedog`.`review_recommend_history` ( +`recommend_history_id` INT NOT NULL AUTO_INCREMENT, +`review_id` INT NOT NULL, +`user_id` INT NOT NULL, +`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, +PRIMARY KEY (`recommend_history_id`), +INDEX `review_review_id_fk_idx` (`review_id` ASC) VISIBLE, +INDEX `recommend_history_user_user_id_fk_idx` (`user_id` ASC) VISIBLE, +CONSTRAINT `recommend_history_review_review_id_fk` + FOREIGN KEY (`review_id`) + REFERENCES `catsgotogedog`.`content_review` (`review_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION, +CONSTRAINT `recommend_history_user_user_id_fk` + FOREIGN KEY (`user_id`) + REFERENCES `catsgotogedog`.`user` (`user_id`) + ON DELETE CASCADE + ON UPDATE NO ACTION); + +ALTER TABLE `catsgotogedog`.`review_recommend_history` + ADD UNIQUE INDEX `review_id_user_id_recommend_uq` (`user_id` ASC, `review_id` ASC) VISIBLE; +ALTER TABLE `catsgotogedog`.`review_recommend_history` ALTER INDEX `recommend_history_user_user_id_fk_idx` INVISIBLE; + +ALTER TABLE `catsgotogedog`.`content_review` + CHANGE COLUMN `like` `recommended_number` INT NULL DEFAULT '0' ; diff --git a/src/main/resources/db/migration/mysql/V8__pet_guide_dataType_extend.sql b/src/main/resources/db/migration/mysql/V8__pet_guide_dataType_extend.sql new file mode 100644 index 0000000..701e7a5 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V8__pet_guide_dataType_extend.sql @@ -0,0 +1,7 @@ +ALTER TABLE `catsgotogedog`.`pet_guide` + CHANGE COLUMN `accident_prep` `accident_prep` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `provided_item` `provided_item` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `purchasable_item` `purchasable_item` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `rent_item` `rent_item` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `pet_prep` `pet_prep` VARCHAR(200) NULL DEFAULT NULL , + CHANGE COLUMN `with_pet` `with_pet` VARCHAR(200) NULL DEFAULT NULL ; diff --git a/src/main/resources/db/migration/mysql/V9__add_name_update_at_to_user_table_and_insert_pet_size_data.sql b/src/main/resources/db/migration/mysql/V9__add_name_update_at_to_user_table_and_insert_pet_size_data.sql new file mode 100644 index 0000000..f266315 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V9__add_name_update_at_to_user_table_and_insert_pet_size_data.sql @@ -0,0 +1,5 @@ +ALTER TABLE `catsgotogedog`.`user` +ADD COLUMN `name_update_at` DATETIME NULL; +INSERT INTO `catsgotogedog`.`pet_size` (`size`, `size_tooltip`) VALUES ('소형', '소형견: 성견 된 몸무게가 대략 10kg 미만 (성견: 생후 2년 이상)'); +INSERT INTO `catsgotogedog`.`pet_size` (`size`, `size_tooltip`) VALUES ('중형', '중형견: 성견 된 몸무게가 대략 10~25kg 미만'); +INSERT INTO `catsgotogedog`.`pet_size` (`size`, `size_tooltip`) VALUES ('대형', '대형견: 성견 된 몸무게가 대략 25kg 이상'); diff --git a/src/main/resources/elasticsearch/content-embed-mapping.json b/src/main/resources/elasticsearch/content-embed-mapping.json new file mode 100644 index 0000000..20e5947 --- /dev/null +++ b/src/main/resources/elasticsearch/content-embed-mapping.json @@ -0,0 +1,16 @@ +{ + "properties": { + "contentId": { "type": "integer" }, + "contentTypeId": { "type": "integer" }, + "categoryId": { "type": "keyword" }, + "sidoCode": { "type": "integer" }, + "sigunguCode": { "type": "integer" }, + "mapx": { "type": "double" }, + "mapy": { "type": "double" }, + "title": { "type": "keyword" }, + "embedding": { + "type": "dense_vector", + "dims": 1024 + } + } +} \ No newline at end of file diff --git a/src/main/resources/elasticsearch/content-embed-setting.json b/src/main/resources/elasticsearch/content-embed-setting.json new file mode 100644 index 0000000..9761fcd --- /dev/null +++ b/src/main/resources/elasticsearch/content-embed-setting.json @@ -0,0 +1,6 @@ +{ + "index": { + "number_of_shards": 1, + "number_of_replicas": 1 + } +} \ No newline at end of file diff --git a/src/main/resources/elasticsearch/search-mapping.json b/src/main/resources/elasticsearch/search-mapping.json new file mode 100644 index 0000000..7051e99 --- /dev/null +++ b/src/main/resources/elasticsearch/search-mapping.json @@ -0,0 +1,55 @@ +{ + "properties": { + "contentId": { + "type": "integer" + }, + "categoryId": { + "type": "text" + }, + "regionId": { + "type": "integer" + }, + "addr1": { + "type": "text" + }, + "addr2": { + "type": "text" + }, + "image": { + "type": "keyword" + }, + "thumbImage": { + "type": "keyword" + }, + "copyright": { + "type": "text" + }, + "mapx": { + "type": "double" + }, + "mapy": { + "type": "double" + }, + "mlevel": { + "type": "integer" + }, + "tel": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "zipcode": { + "type": "integer" + }, + "contentTypeId": { + "type": "integer" + }, + "sidoCode": { + "type": "integer" + }, + "sigunguCode": { + "type": "integer" + } + } +} diff --git a/src/main/resources/elasticsearch/search-setting.json b/src/main/resources/elasticsearch/search-setting.json new file mode 100644 index 0000000..b400a4f --- /dev/null +++ b/src/main/resources/elasticsearch/search-setting.json @@ -0,0 +1,9 @@ +{ + "analysis": { + "analyzer": { + "korean": { + "type": "nori" + } + } + } +} \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..eaa1ddd --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + ${LOG_PATTERN_CONSOLE} + + + + + + + + ${INFO_LOG_DIR}info_%d{yyyy-MM-dd}.log + ${MAX_HISTORY} + + + ${LOG_PATTERN_FILE} + + + + + + + + ERROR + ACCEPT + DENY + + + ${ERROR_LOG_DIR}error_%d{yyyy-MM-dd}.log + ${MAX_HISTORY} + + + ${LOG_PATTERN_FILE} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java b/src/test/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java index 9367ee9..36af000 100644 --- a/src/test/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java +++ b/src/test/java/com/swyp/catsgotogedog/CatsgotogedogApplicationTests.java @@ -1,11 +1,18 @@ package com.swyp.catsgotogedog; +import com.swyp.catsgotogedog.content.repository.ContentElasticRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockReset; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest class CatsgotogedogApplicationTests { + @MockitoBean(reset = MockReset.AFTER) + ContentElasticRepository contentElasticRepository; + + @Test void contextLoads() { }