diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..5ef23e5a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature request +about: 추가될 기능에 대해 제안해주세요! +title: "[✨FEATURE]" +labels: "✨ Feature" +assignees: '' + +--- + +**🚀 기능 설명** +추가하고 싶은 기능에 대해 명확하고 간결하게 설명해주세요. + +**🔍 원하는 솔루션 설명** + +**🙌 해야 할 일** +- [ ] 할일 1 +- [ ] 할일 2 +- [ ] 할일 3 + +**❓ 고려한 대안들** +고려한 대체 솔루션이나 기능에 대해 설명해주세요. + +**📜 추가 내용** +기능 요청에 대한 다른 맥락이나 스크린샷을 추가해주세요. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5c963598 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## 📌 요약 + +- + +## 📝 상세 내용 + +- + +## 🗣️ 질문 및 이외 사항 + +- + +### ☑️ 누구에게 리뷰를 요청할까요? diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 00000000..9561727b --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,89 @@ +name: 'CI/CD' + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + CI: + runs-on: ubuntu-latest + env: + KAKAO_CLIENT_ID: ${{ secrets.TEST_STRING_ENV }} + KAKAO_CLIENT_SECRET: ${{ secrets.TEST_STRING_ENV }} + KAKAO_REDIRECT_URI: ${{ secrets.TEST_STRING_ENV }} + JWT_SECRET_KEY: ${{ secrets.TEST_JWT_KEY }} + JWT_REFRESH_EXPIRE: ${{ secrets.TEST_INT_ENV }} + JWT_ACCESS_EXPIRE: ${{ secrets.TEST_INT_ENV }} + AWS_BUCKET_NAME: ${{ secrets.TEST_STRING_ENV }} + AWS_ACCESS_KEY: ${{ secrets.TEST_STRING_ENV }} + AWS_SECRET_KEY: ${{ secrets.TEST_STRING_ENV }} + AWS_REGION: 'ap-northeast-2' + FLASK_SERVER: ${{ secrets.TEST_STRING_ENV }} + FRONT_URL: ${{ secrets.TEST_STRING_ENV }} + BACKEND_DOMAIN: ${{ secrets.TEST_STRING_ENV }} + steps: + - name: Check out repository + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + java-version: '17' + distribution: 'temurin' + + - name: Build with Gradle + run: ./gradlew build --no-daemon + + - name: Run tests + run: ./gradlew test --no-daemon + + CD: + if: ${{ github.event_name != 'pull_request' && success() }} + needs: CI + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Build + uses: docker/setup-buildx-action@v1 + + - name: Build Docker image + run: | + docker compose build --no-cache + docker tag server ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:server + docker tag nginx ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:nginx + + - name: Push Docker image to Docker Hub + run: | + docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:server + docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:nginx + + - name: Deploy + uses: appleboy/ssh-action@v1.1.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + script: | + echo ${{ secrets.DOCKER_PASSWORD }} | sudo docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + + sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:server + sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:nginx + sudo docker pull prom/prometheus:latest + sudo docker pull grafana/grafana:latest + + sudo docker-compose down + sudo docker-compose up -d + sudo docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b2669ae2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +/src/main/resources/application-local.yml diff --git a/README.md b/README.md index c892e268..cff310e9 100644 --- a/README.md +++ b/README.md @@ -1 +1,102 @@ -# claco-server \ No newline at end of file +# Claco 메인 서버 레포지토리 + +## 🧑‍💻 R&R +| Profile | Name | Role | +| :---: | :---: | :---: | +| | 이건(개발리드)
**devkeon**| 아키텍처 설계, ERD 설계, 메인 서버 인프라 및 CI/CD 구축,
인증/인가, 모니터링 시스템 구축, 티켓/리뷰 기능,
클라코북 기능, 회원 관련 기능| +| | 정희찬
**anselmo**| ERD 설계, AI 및 배치 서버 인프라 및 CI/CD 구축,
추천 AI 모델 구현, 배치 기능(데이터 로드) 구축,
공연 기능, 공연 및 티켓 추천 기능| + +## 개발 내용 + +### 📆 개발 기간 +- ***2024.10.05 ~ 2024.11.24*** + +### 💻 개발 환경 +> Language: ```Java 17```
+> Framework: ```Spring Boot 3.3.4```
+> Database: ```MySQL 8.x```
+> ORM: ```JPA(Hibernate)```
+> CI/CD: ```Github Actions```
+> Cloud Platform: ```AWS(EC2, ALB, ACM), GCP(SQL)```
+> Test DB: ```testcontainer``` + +### ⚙️ 개발 프로세스 +- ```TDD (테스트 주도 개발)``` : 구문 커버지리 (Statement coverage) 기준 80%를 목표로 수행 +- ```Agile (애자일 프로세스)``` : 1주 단위 스프린트 수행 +- ```Github Flow 전략``` : 초기 개발 과정에서 불필요한 브랜치 관리를 피하고, 빠른 배포를 위한 전략 선택 +- ```CI/CD 파이프라인을 통한 배포 자동화``` : 서비스 개발이 50% 완료된 시점에서 구축하여 배포 자동화 + +### 💫 TDD 결과 +- Service는 단위 테스트, Repository는 통합 테스트 진행 +- ```testcontainer```를 활용하여 데이터베이스 멱등성 보장 +- 테스트 코드 커버리지 측정 툴: ```IntelliJ```
+ +![img.png](readme/test-coverage.png) +- summary + - statement coverage 기준: 88% + - branch coverage: 54.8% + - class coverage: 100% + - method coverage: 96.7% + +### 💫 부하 테스트 결과 +- 사용 인스턴스 유형: ```t2.large (ram 8GB)``` +- 부하 테스트 측정 툴: ```Jmeter``` + +![server-test.png](readme/server-test.png) +- summary + - 도메인별 주요 api 평균 50.3 Throughput + +## 🏛️ 아키텍처 +![architecture.png](readme/architecture.png) + +### 보안 고려 사항 +- JWT를 활용한 인증/인가 + - SSL 보안 계층을 활용한 토큰 암호화 (HTTPS, ALB 설치) + - CSRF / XSS 공격에 대비한 토큰 저장 분리 (Local storage, HTTP-only Cookie) +- Nginx를 활용한 actuator와 같은 민감 정보 deny +- Spring Security를 활용한 철저한 Auth 검사 및 uri 접근 조정 +- Kakao OAuth2.0을 활용한 인증/인가 기능 간편화 +- docker 네트워크를 활용하여 spring 서버나, prometheus같은 인스턴스 포트 매핑x (Endpoint 단일화) + +### 추천 시스템 로직 + +- Collaborative Filtering & Cosine Similarity 기반 추천시스템 + 1. 각 Concert는 AI가 추출해준 키워드 값에 대해 0 ~ 1 사이의 값을 가짐 + 2. 유저도 마찬가지로 온보딩에서 등록한 취향 정보로 부터 모든 키워드 값에 대해 0 ~ 1사이 값을 가짐 + 3. Concerts, Users CSV파일을 통해서 Cosine Similarity와 Collaborative Filtering을 통한 유사도 계산 후 추천 진행 + +### 메인 서버 +- 서비스의 주요 로직을 처리하는 서버 +- Grafana와 Prometheus에 기반한 모니터링 시스템 구축 +- Nginx를 통한 리버스 프록시 설정 + +### AI 서버 + +- 공연 성격 분석이나, 유저 성격 분석, OCR을 처리하는 서버 +- OCR 및 공연 성격 정보 추출은 NCP의 AI 서비스를 활용 +- 추천 시스템의 경우 직접 Collaborative Filtering Model 구현 + +### 배치 서버 + +- KOPIS 시스템으로부터 공연 정보를 주기적으로 업데이트하는 서버(한달에 1번) +- KOPIS에서 데이터를 받아올때마다 AI서버에 학습 요청 + +### 🔄 FlowChart of AI & Batch Server +![flow-chart.png](readme/ai-flow.png) + +## 📁 ERD +![erd.png](readme/erd.png) + +- 카테고리에서 연관 관계 설정을 통해 관계형 데이터베이스 활용 +- AI 서비스 학습을 위한 soft delete 활용 + +## 🛠️ CI/CD pipeline +![ci-cd.png](readme/cicd.png) +1. PR 이벤트 발생 시 CI 실행 (테스트 포함) +2. approve 및 CI 성공 시 merge 가능 +3. merge 이벤트 발생 시 CI 스크립트 수행 +4. CI 스크립트 성공 시 CD 스크립트 수행 +5. Docker 이미지 docker hub에 push +6. SSH로 AWS EC2 연결 +7. docker hub에서 이미지 pull +8. dokcer-compose를 활용해 서비스 실행 및 도커 네트워크 구축 diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..83b3df7e --- /dev/null +++ b/build.gradle @@ -0,0 +1,70 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.4' + id 'io.spring.dependency-management' version '1.1.6' +} + +group = 'com.curateme' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + + // JWT dependencies + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // Testcontainer + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:mysql' + + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + implementation 'org.springframework.boot:spring-boot-starter-json' + + + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // swagger + implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.2.0' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..0830b396 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: "3.8" + +services: + server: + build: + context: . + dockerfile: ./dockerfiles/dockerfile-server + image: server + container_name: server + env_file: + - ~/env.list + nginx: + build: + context: ./dockerfiles + dockerfile: dockerfile-nginx + image: nginx + container_name: nginx + ports: + - "80:80" diff --git a/dockerfiles/dockerfile-nginx b/dockerfiles/dockerfile-nginx new file mode 100644 index 00000000..73f26815 --- /dev/null +++ b/dockerfiles/dockerfile-nginx @@ -0,0 +1,7 @@ +FROM nginx:latest + +COPY nginx.conf /etc/nginx/nginx.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/dockerfiles/dockerfile-server b/dockerfiles/dockerfile-server new file mode 100644 index 00000000..c4d36e35 --- /dev/null +++ b/dockerfiles/dockerfile-server @@ -0,0 +1,9 @@ +FROM gradle:8.8-jdk17 AS build + +COPY --chown=gradle:gradle ../ /home/gradle/src +WORKDIR /home/gradle/src +RUN gradle build --no-daemon --warning-mode=all --scan -x test + +FROM openjdk:17 +COPY --from=build /home/gradle/src/build/libs/*.jar /app/spring-boot-application.jar +ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "/app/spring-boot-application.jar"] diff --git a/dockerfiles/nginx.conf b/dockerfiles/nginx.conf new file mode 100644 index 00000000..9109bf5b --- /dev/null +++ b/dockerfiles/nginx.conf @@ -0,0 +1,28 @@ +user nginx; +worker_processes 1; + +events { + worker_connections 4096; +} + +http { + include mime.types; + default_type application/octet-stream; + client_max_body_size 15M; + + server { + listen 80; + + location /actuator { + deny all; + } + + location / { + proxy_pass http://server:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df97d72b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/readme/ai-flow.png b/readme/ai-flow.png new file mode 100644 index 00000000..4a7cc6e4 Binary files /dev/null and b/readme/ai-flow.png differ diff --git a/readme/architecture.png b/readme/architecture.png new file mode 100644 index 00000000..1da70774 Binary files /dev/null and b/readme/architecture.png differ diff --git a/readme/cicd.png b/readme/cicd.png new file mode 100644 index 00000000..6d3ed268 Binary files /dev/null and b/readme/cicd.png differ diff --git a/readme/erd.png b/readme/erd.png new file mode 100644 index 00000000..f8e626c5 Binary files /dev/null and b/readme/erd.png differ diff --git a/readme/server-test.png b/readme/server-test.png new file mode 100644 index 00000000..e6934c53 Binary files /dev/null and b/readme/server-test.png differ diff --git a/readme/test-coverage.png b/readme/test-coverage.png new file mode 100644 index 00000000..0e5218f0 Binary files /dev/null and b/readme/test-coverage.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..e058b513 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'claco' diff --git a/src/main/java/com/curateme/claco/ClacoApplication.java b/src/main/java/com/curateme/claco/ClacoApplication.java new file mode 100644 index 00000000..3b477031 --- /dev/null +++ b/src/main/java/com/curateme/claco/ClacoApplication.java @@ -0,0 +1,13 @@ +package com.curateme.claco; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ClacoApplication { + + public static void main(String[] args) { + SpringApplication.run(ClacoApplication.class, args); + } + +} diff --git a/src/main/java/com/curateme/claco/authentication/controller/AuthenticationController.java b/src/main/java/com/curateme/claco/authentication/controller/AuthenticationController.java new file mode 100644 index 00000000..b692724d --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/controller/AuthenticationController.java @@ -0,0 +1,32 @@ +package com.curateme.claco.authentication.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.curateme.claco.authentication.service.AuthenticationService; +import com.curateme.claco.global.response.ApiResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthenticationController { + + private final AuthenticationService authenticationService; + + /** + * 리프레시 쿠키 발급 + */ + @GetMapping("/refresh") + public ApiResponse getRefreshCookie(HttpServletRequest request, HttpServletResponse response) { + + authenticationService.getRefreshToken(request, response); + return ApiResponse.ok(); + } + + +} diff --git a/src/main/java/com/curateme/claco/authentication/domain/JwtMemberDetail.java b/src/main/java/com/curateme/claco/authentication/domain/JwtMemberDetail.java new file mode 100644 index 00000000..0d49d118 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/domain/JwtMemberDetail.java @@ -0,0 +1,33 @@ +package com.curateme.claco.authentication.domain; + +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; + +import lombok.Builder; +import lombok.Getter; + +/** + * @packageName : com.curateme.claco.authentication.domain + * @fileName : JwtMemberDetail.java + * @author : 이 건 + * @date : 2024.10.17 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.17 이 건 최초 생성 + */ +@Getter +public class JwtMemberDetail extends User { + + // member 의 PK 값 + private Long memberId; + + @Builder(builderMethodName = "JwtMemberDetailBuilder") + public JwtMemberDetail(String username, Collection authorities, Long memberId) { + super(username, "", authorities); + this.memberId = memberId; + } +} diff --git a/src/main/java/com/curateme/claco/authentication/domain/oauth2/CustomOAuth2User.java b/src/main/java/com/curateme/claco/authentication/domain/oauth2/CustomOAuth2User.java new file mode 100644 index 00000000..2c072822 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/domain/oauth2/CustomOAuth2User.java @@ -0,0 +1,34 @@ +package com.curateme.claco.authentication.domain.oauth2; + +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import com.curateme.claco.member.domain.entity.Member; + +import lombok.Getter; + +/** + * @fileName : CustomOAuth2User.java + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + */ +@Getter +public class CustomOAuth2User extends DefaultOAuth2User { + + private final Member member; + + public CustomOAuth2User(Collection authorities, + Map attributes, String nameAttributeKey, Member member) { + super(authorities, attributes, nameAttributeKey); + this.member = member; + } + +} diff --git a/src/main/java/com/curateme/claco/authentication/domain/oauth2/KakaoOAuthAttribute.java b/src/main/java/com/curateme/claco/authentication/domain/oauth2/KakaoOAuthAttribute.java new file mode 100644 index 00000000..2902494e --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/domain/oauth2/KakaoOAuthAttribute.java @@ -0,0 +1,62 @@ +package com.curateme.claco.authentication.domain.oauth2; + +import java.util.Map; + +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; + +import lombok.Builder; +import lombok.Getter; + +/** + * @fileName : KakaoOAuthAttribute.java + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + * 2024.11.05 이 건 기본 이미지 url 추가 + */ +@Getter +public class KakaoOAuthAttribute { + + private String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드 값, PK와 같은 의미 + private Oauth2UserInfo oauth2UserInfo; + private PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + private String baseProfileImage = "https://claco-image-bucket.s3.ap-northeast-2.amazonaws.com/member/profile-image/basic-profile.png"; + + @Builder + private KakaoOAuthAttribute(String nameAttributeKey, Oauth2UserInfo oauth2UserInfo) { + this.nameAttributeKey = nameAttributeKey; + this.oauth2UserInfo = oauth2UserInfo; + } + + public KakaoOAuthAttribute(String nameAttributeKey) { + this.nameAttributeKey = nameAttributeKey; + } + + // 정적 팩토리 메서드 + public static KakaoOAuthAttribute of(String nameAttributeKey, Map attribute) { + return KakaoOAuthAttribute.builder() + .nameAttributeKey(nameAttributeKey) + .oauth2UserInfo(new KakaoOAuthUserInfo(attribute)) + .build(); + } + + // 엔티티 변환 메서드 + public Member toEntity(Oauth2UserInfo kakaoOAuthUserInfo) { + return Member.builder() + .email(kakaoOAuthUserInfo.getEmail().orElseThrow(() -> new BusinessException(ApiStatus.OAUTH_ATTRIBUTE_ERROR))) + .profileImage(kakaoOAuthUserInfo.getProfileImage().orElse(baseProfileImage)) + .socialId(kakaoOAuthUserInfo.getId()) + .role(Role.SOCIAL) + .build(); + } +} diff --git a/src/main/java/com/curateme/claco/authentication/domain/oauth2/KakaoOAuthUserInfo.java b/src/main/java/com/curateme/claco/authentication/domain/oauth2/KakaoOAuthUserInfo.java new file mode 100644 index 00000000..f19acd19 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/domain/oauth2/KakaoOAuthUserInfo.java @@ -0,0 +1,59 @@ +package com.curateme.claco.authentication.domain.oauth2; + +import java.util.Map; +import java.util.Optional; + +import lombok.extern.slf4j.Slf4j; + +/** + * @fileName : KakaoOAuthUserInfo.java + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + */ +@Slf4j +public class KakaoOAuthUserInfo extends Oauth2UserInfo{ + + public KakaoOAuthUserInfo(Map attributes) { + super(attributes); + } + + // OAuth 자체 id (socialId) + @Override + public Long getId() { + return (Long) attributes.get("id"); + } + + // email 정보 + @Override + public Optional getEmail() { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + + if (kakaoAccount == null) return Optional.empty(); + + boolean isEmailVerified = (boolean) kakaoAccount.get("is_email_verified"); + boolean isEmailValid = (boolean) kakaoAccount.get("is_email_valid"); + + if (!isEmailValid || !isEmailVerified) return Optional.empty(); + + return Optional.of((String) kakaoAccount.get("email")); + } + + // 프로필 이미지 정보 + @Override + public Optional getProfileImage() { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + + if (kakaoAccount == null) return Optional.empty(); + + Map profile = (Map) kakaoAccount.get("profile"); + + if (profile == null) return Optional.empty(); + + return Optional.of((String) profile.get("thumbnail_image_url")); + } +} diff --git a/src/main/java/com/curateme/claco/authentication/domain/oauth2/Oauth2UserInfo.java b/src/main/java/com/curateme/claco/authentication/domain/oauth2/Oauth2UserInfo.java new file mode 100644 index 00000000..421945d8 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/domain/oauth2/Oauth2UserInfo.java @@ -0,0 +1,33 @@ +package com.curateme.claco.authentication.domain.oauth2; + +import java.util.Map; +import java.util.Optional; + +/** + * @fileName : Oauth2UserInfo.java + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + */ +public abstract class Oauth2UserInfo { + + // OAuth attribute 객체 + protected Map attributes; + + public Oauth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + // OAuth 자체 id (socialId) + public abstract Long getId(); // 카카오 - "id" + + // email 정보 + public abstract Optional getEmail(); + + // 프로필 이미지 정보 + public abstract Optional getProfileImage(); +} diff --git a/src/main/java/com/curateme/claco/authentication/filter/JwtAuthenticationFilter.java b/src/main/java/com/curateme/claco/authentication/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..34b9b720 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/filter/JwtAuthenticationFilter.java @@ -0,0 +1,127 @@ +package com.curateme.claco.authentication.filter; + +import java.io.IOException; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.curateme.claco.authentication.util.JwtTokenUtil; +import com.curateme.claco.authentication.util.RefreshContextHolder; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenUtil jwtTokenUtil; + private final MemberRepository memberRepository; + + @Value("${jwt.cookie.expire}") + private Integer COOKIE_EXPIRATION; + + private final static String GRANT_TYPE = "Bearer "; + + protected List filterPassList = List.of("/oauth2/authorization/kakao", + "/login/oauth2/code/kakao", "/favicon.ico", "/v3/api-docs", "/v3/api-docs/swagger-config", "/health-check" + ); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String requestUri = request.getRequestURI(); + + if (filterPassList.contains(requestUri) || requestUri.startsWith("/swagger") || requestUri.startsWith("/actuator")) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = jwtTokenUtil.extractAccessToken(request).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.ACCESS_TOKEN_NOT_FOUND)); + + Authentication authentication; + + // 정상 흐름 + try { + authentication = jwtTokenUtil.getAuthentication(accessToken); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + jwtTokenUtil.extractRefreshToken(request).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.REFRESH_TOKEN_NOT_FOUND)); + + response.setHeader("Authorization", GRANT_TYPE + accessToken); + + // access token 만료 흐름 + } catch (ExpiredJwtException e) { + + log.info("[AccessTokenExpire] -> expireMemberId: {}", e.getClaims().get("id")); + + Claims claims = e.getClaims(); + + String refreshToken = jwtTokenUtil.extractRefreshToken(request).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.REFRESH_TOKEN_NOT_FOUND)); + + if (!jwtTokenUtil.validate(refreshToken)) { + throw new BusinessException(ApiStatus.MEMBER_LOGIN_SESSION_EXPIRED); + } + + Member currentMember = memberRepository.findById(Long.parseLong(claims.get("id").toString())).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + if (!currentMember.getRefreshToken().equals(refreshToken)) { + throw new BusinessException(ApiStatus.MEMBER_LOGIN_SESSION_EXPIRED); + } + + String generateRefreshToken = jwtTokenUtil.generateRefreshToken(); + + currentMember.updateRefreshToken(generateRefreshToken); + memberRepository.save(currentMember); + + Authentication createdAuthentication = jwtTokenUtil.createAuthentication(currentMember); + + String generatedAccessToken = jwtTokenUtil.generateAccessToken(createdAuthentication); + + response.setHeader("Authorization", generatedAccessToken); + RefreshContextHolder.setRefreshed(true); + + ResponseCookie cookie = ResponseCookie.from("refreshToken", generateRefreshToken) + .path("/") + .httpOnly(true) + .maxAge(COOKIE_EXPIRATION) + .sameSite("None") + .secure(true) + .build(); + + response.setHeader("Set-Cookie", String.valueOf(cookie)); + + SecurityContextHolder.getContext().setAuthentication(createdAuthentication); + + } + + filterChain.doFilter(request, response); + + SecurityContextHolder.clearContext(); + RefreshContextHolder.clear(); + } +} diff --git a/src/main/java/com/curateme/claco/authentication/handler/oauth/OAuthLoginFailureHandler.java b/src/main/java/com/curateme/claco/authentication/handler/oauth/OAuthLoginFailureHandler.java new file mode 100644 index 00000000..e7c814c5 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/handler/oauth/OAuthLoginFailureHandler.java @@ -0,0 +1,49 @@ +package com.curateme.claco.authentication.handler.oauth; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.global.response.ApiStatus; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @fileName : OAuthLoginFailureHandler.java + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuthLoginFailureHandler implements AuthenticationFailureHandler { + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + + String responseBody = objectMapper.writeValueAsString(ApiResponse.fail(ApiStatus.MEMBER_NOT_FOUND)); + response.getWriter().write(responseBody); + + log.error("[OauthFail] -> message: {}", exception.getMessage()); + + } +} diff --git a/src/main/java/com/curateme/claco/authentication/handler/oauth/OAuthLoginSuccessHandler.java b/src/main/java/com/curateme/claco/authentication/handler/oauth/OAuthLoginSuccessHandler.java new file mode 100644 index 00000000..798e6a80 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/handler/oauth/OAuthLoginSuccessHandler.java @@ -0,0 +1,82 @@ +package com.curateme.claco.authentication.handler.oauth; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.curateme.claco.authentication.domain.oauth2.CustomOAuth2User; +import com.curateme.claco.authentication.util.JwtTokenUtil; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; +import com.curateme.claco.member.repository.MemberRepository; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@Transactional +@RequiredArgsConstructor +public class OAuthLoginSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtTokenUtil jwtTokenUtil; + private final MemberRepository memberRepository; + + @Value("${front.url}") + private String frontUrl; + @Value("${jwt.cookie.expire}") + private Integer COOKIE_EXPIRATION; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + CustomOAuth2User oAuthUser = (CustomOAuth2User) authentication.getPrincipal(); + + Member member = memberRepository.findById(oAuthUser.getMember().getId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + Authentication authentication1 = jwtTokenUtil.createAuthentication(member); + + String accessToken = jwtTokenUtil.generateAccessToken(authentication1); + + String generateRefreshToken = jwtTokenUtil.generateRefreshToken(); + + ResponseCookie cookie = ResponseCookie.from("refreshToken", generateRefreshToken) + .path("/") + .httpOnly(true) + .maxAge(COOKIE_EXPIRATION) + .sameSite("None") + .secure(true) + .build(); + + response.setHeader("Set-Cookie", String.valueOf(cookie)); + response.setHeader("Access-Control-Allow-Origin", frontUrl); + response.setHeader("Access-Control-Allow-Credentials", "true"); + + String redirectUrl = frontUrl + "/oauth/callback/main?token=" + + URLEncoder.encode(accessToken, StandardCharsets.UTF_8); + + if (member.getRole() == Role.SOCIAL) { + redirectUrl = frontUrl + "/oauth/callback/sign-up?token=" + + URLEncoder.encode(accessToken, StandardCharsets.UTF_8); + } else{ + redirectUrl += ("&nickname=" + URLEncoder.encode(member.getNickname(), StandardCharsets.UTF_8)); + } + + response.sendRedirect(redirectUrl); + } +} diff --git a/src/main/java/com/curateme/claco/authentication/service/AuthenticationService.java b/src/main/java/com/curateme/claco/authentication/service/AuthenticationService.java new file mode 100644 index 00000000..01480836 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/service/AuthenticationService.java @@ -0,0 +1,63 @@ +package com.curateme.claco.authentication.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.curateme.claco.authentication.util.JwtTokenUtil; +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class AuthenticationService { + + private final MemberRepository memberRepository; + private final SecurityContextUtil securityContextUtil; + private final JwtTokenUtil jwtTokenUtil; + + @Value("${jwt.cookie.expire}") + private Integer COOKIE_EXPIRATION; + @Value("${front.url}") + private String frontUrl; + @Value("${backend.domain}") + private String backUrl; + + /** + * 리프레시 쿠키 생성 + */ + public void getRefreshToken(HttpServletRequest request, HttpServletResponse response) { + + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + // refresh token 생성 및 업데이트 + String refreshToken = jwtTokenUtil.generateRefreshToken(); + + member.updateRefreshToken(refreshToken); + + ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) + .path("/") + .httpOnly(true) + .sameSite("None") + .maxAge(COOKIE_EXPIRATION) + .secure(true) + .domain(backUrl) + .build(); + + response.setHeader("Set-Cookie", cookie.toString()); + response.setHeader("Access-Control-Allow-Origin", frontUrl); + response.setHeader("Access-Control-Allow-Credentials", "true"); + } + +} diff --git a/src/main/java/com/curateme/claco/authentication/service/CustomOAuth2UserService.java b/src/main/java/com/curateme/claco/authentication/service/CustomOAuth2UserService.java new file mode 100644 index 00000000..f7f63ed8 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/service/CustomOAuth2UserService.java @@ -0,0 +1,91 @@ +package com.curateme.claco.authentication.service; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.curateme.claco.authentication.domain.oauth2.CustomOAuth2User; +import com.curateme.claco.authentication.domain.oauth2.KakaoOAuthAttribute; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @fileName : CustomOAuth2UserService.java + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + */ +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + log.info("[OAuth2.0] -> service start: clientId={}", userRequest.getClientRegistration().getClientId()); + + DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + String userNameAttributeName = userRequest.getClientRegistration() + .getProviderDetails() + .getUserInfoEndpoint() + .getUserNameAttributeName(); + + Map attributes = oAuth2User.getAttributes(); + + KakaoOAuthAttribute extractAttributes = KakaoOAuthAttribute.of(userNameAttributeName, attributes); + + Member createdUser = getMember(extractAttributes); + + log.info("[OAuth2.0] -> service end: clientId={} / socialId={}", userRequest.getClientRegistration().getClientId(), createdUser.getSocialId()); + + return new CustomOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_" + createdUser.getRole().getRole())), + attributes, + extractAttributes.getNameAttributeKey(), + createdUser + ); + + } + + private Member getMember(KakaoOAuthAttribute kakaoOAuthAttribute) { + + Optional findUser = memberRepository.findMemberBySocialId(kakaoOAuthAttribute.getOauth2UserInfo().getId()); + + if(findUser.isEmpty()) { + Member member = saveMember(kakaoOAuthAttribute); + + return member; + } + + return findUser.get(); + + } + + private Member saveMember(KakaoOAuthAttribute attributes) { + Member createdUser = attributes.toEntity(attributes.getOauth2UserInfo()); + return memberRepository.save(createdUser); + } +} diff --git a/src/main/java/com/curateme/claco/authentication/util/JwtTokenUtil.java b/src/main/java/com/curateme/claco/authentication/util/JwtTokenUtil.java new file mode 100644 index 00000000..83b5ec0a --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/util/JwtTokenUtil.java @@ -0,0 +1,45 @@ +package com.curateme.claco.authentication.util; + +import java.util.Optional; + +import org.springframework.security.core.Authentication; + +import com.curateme.claco.member.domain.entity.Member; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * @packageName : com.curateme.claco.authentication.util + * @fileName : JwtTokenUtil.java + * @author : 이 건 + * @date : 2024.10.17 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.17 이 건 최초 생성 + */ +public interface JwtTokenUtil { + + // Authentication 으로부터 엑세스 토큰 발급 + String generateAccessToken(Authentication authentication); + + // RefreshToken 생성 + String generateRefreshToken(); + + // AccessToken 으로부터 Authentication 발급 + Authentication getAuthentication(String accessToken); + + // Member 엔티티로부터 Authentication 발급 + Authentication createAuthentication(Member member); + + // HttpServletRequest 로부터 엑세스 토큰 추출 + Optional extractAccessToken(HttpServletRequest request); + + // Refresh Token 쿠키에서 추출 메서드 + Optional extractRefreshToken(HttpServletRequest request); + + // 토큰 유효성 검증 메서드 + public boolean validate(String token); + +} diff --git a/src/main/java/com/curateme/claco/authentication/util/JwtTokenUtilImpl.java b/src/main/java/com/curateme/claco/authentication/util/JwtTokenUtilImpl.java new file mode 100644 index 00000000..6d644102 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/util/JwtTokenUtilImpl.java @@ -0,0 +1,182 @@ +package com.curateme.claco.authentication.util; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +/** + * @packageName : com.curateme.claco.authentication.util + * @fileName : JwtTokenUtilImpl.java + * @author : 이 건 + * @date : 2024.10.17 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.17 이 건 최초 생성 + */ +@Slf4j +@Component +public class JwtTokenUtilImpl implements JwtTokenUtil{ + private final SecretKey key; + private final Long accessExpiration; + private final Long refreshExpiration; + private final MemberRepository memberRepository; + + private static final String REFRESH_TOKEN_SUBJECT = "refreshToken"; + private static final String GRANT_TYPE = "Bearer "; + + public JwtTokenUtilImpl(@Value("${jwt.token.secret-key}") String secretKey, @Value("${jwt.token.expire.access}") Long accessExpiration, + @Value("${jwt.token.expire.refresh}") Long refreshExpiration, MemberRepository repository) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + this.accessExpiration = accessExpiration; + this.refreshExpiration = refreshExpiration; + this.memberRepository = repository; + } + + // Authentication 으로부터 엑세스 토큰 발급 + @Override + public String generateAccessToken(Authentication authentication) { + + String authority = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + JwtMemberDetail jwtMemberDetail = (JwtMemberDetail) authentication.getPrincipal(); + + return GRANT_TYPE + Jwts.builder() + .subject(authentication.getName()) + .claim("auth", authority) + .claim("id", jwtMemberDetail.getMemberId()) + .signWith(key) + .expiration(Date.from(Instant.now().plusMillis(accessExpiration))) + .compact(); + } + + // RefreshToken 생성 + @Override + public String generateRefreshToken() { + + return Jwts.builder() + .subject(REFRESH_TOKEN_SUBJECT) + .expiration(Date.from(Instant.now().plusMillis(refreshExpiration))) + .signWith(key) + .compact(); + } + + // AccessToken 으로부터 Authentication 발급 + @Override + public Authentication getAuthentication(String accessToken) { + + Optional payload = Optional.of(Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(accessToken) + .getPayload()); + + Claims claims = payload.stream() + .filter(claim -> claim.get("auth") != null) + .findAny() + .orElseThrow(() -> new JwtException("error occur from auth")); + + Collection authorities = Arrays.stream(claims.get("auth").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .toList(); + + Member sessionMember = memberRepository.findById(Long.parseLong(claims.get("id").toString())).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + JwtMemberDetail jwtMemberDetail = JwtMemberDetail.JwtMemberDetailBuilder() + .authorities(authorities) + .username(sessionMember.getEmail()) + .memberId(sessionMember.getId()) + .build(); + + return new UsernamePasswordAuthenticationToken(jwtMemberDetail, null, authorities); + } + + // Member 엔티티로부터 Authentication 발급 + @Override + public Authentication createAuthentication(Member member) { + + Collection authorities = List.of( + new SimpleGrantedAuthority("ROLE_" + member.getRole().getRole()) + ); + + JwtMemberDetail jwtMemberDetail = JwtMemberDetail.JwtMemberDetailBuilder() + .authorities(authorities) + .username(member.getEmail()) + .memberId(member.getId()) + .build(); + + return new UsernamePasswordAuthenticationToken(jwtMemberDetail, null, authorities); + + } + + // HttpServletRequest 로부터 엑세스 토큰 추출 + @Override + public Optional extractAccessToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader("Authorization")) + .filter(token -> + token.startsWith(GRANT_TYPE)) + .map(token -> + token.replace(GRANT_TYPE, "")); + } + + // Refresh Token 쿠키에서 추출 메서드 + @Override + public Optional extractRefreshToken(HttpServletRequest request) { + return Optional.ofNullable(request.getCookies()) + .stream() + .flatMap(Arrays::stream) + .filter(cookie -> cookie.getName().equals("refreshToken")) + .findFirst() + .map(Cookie::getValue); + } + + // 토큰 유효성 검증 메서드 + @Override + public boolean validate(String token) { + + try { + Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload(); + return true; + } catch (Exception e) { + log.error(e.getMessage()); + } + return false; + + } + + +} diff --git a/src/main/java/com/curateme/claco/authentication/util/RefreshContextHolder.java b/src/main/java/com/curateme/claco/authentication/util/RefreshContextHolder.java new file mode 100644 index 00000000..48a80f9b --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/util/RefreshContextHolder.java @@ -0,0 +1,20 @@ +package com.curateme.claco.authentication.util; + +/** + * 리프레시 여부 확인 ThreadLocal + */ +public class RefreshContextHolder { + private static final ThreadLocal refreshed = ThreadLocal.withInitial(() -> false); + + public static void setRefreshed(Boolean value) { + refreshed.set(value); + } + + public static Boolean isRefreshed() { + return refreshed.get(); + } + + public static void clear() { + refreshed.remove(); + } +} diff --git a/src/main/java/com/curateme/claco/authentication/util/SecurityContextUtil.java b/src/main/java/com/curateme/claco/authentication/util/SecurityContextUtil.java new file mode 100644 index 00000000..f5c9a991 --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/util/SecurityContextUtil.java @@ -0,0 +1,24 @@ +package com.curateme.claco.authentication.util; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; + +/** + * @packageName : com.curateme.claco.authentication.util + * @fileName : SecurityContextUtil.java + * @author : 이 건 + * @date : 2024.10.17 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.17 이 건 최초 생성 + */ +public interface SecurityContextUtil { + + /** + * 현재 컨텍스트에 있는 유저 정보 반환 + * @return User 객체에 해당하는 JwtMemberDetail + */ + JwtMemberDetail getContextMemberInfo(); + +} diff --git a/src/main/java/com/curateme/claco/authentication/util/SimpleSecurityContextUtil.java b/src/main/java/com/curateme/claco/authentication/util/SimpleSecurityContextUtil.java new file mode 100644 index 00000000..1e0c8dda --- /dev/null +++ b/src/main/java/com/curateme/claco/authentication/util/SimpleSecurityContextUtil.java @@ -0,0 +1,34 @@ +package com.curateme.claco.authentication.util; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; + +import lombok.Getter; + +/** + * @packageName : com.curateme.claco.authentication.util + * @fileName : SimpleSecurityContextUtil.java + * @author : 이 건 + * @date : 2024.10.17 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.17 이 건 최초 생성 + */ +@Getter +@Component +public class SimpleSecurityContextUtil implements SecurityContextUtil { + + /** + * 현재 컨텍스트에 있는 유저 정보 반환 + * @return User 객체에 해당하는 JwtMemberDetail + */ + @Override + public JwtMemberDetail getContextMemberInfo() { + return (JwtMemberDetail) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } + +} diff --git a/src/main/java/com/curateme/claco/clacobook/controller/ClacoBookController.java b/src/main/java/com/curateme/claco/clacobook/controller/ClacoBookController.java new file mode 100644 index 00000000..dfaba970 --- /dev/null +++ b/src/main/java/com/curateme/claco/clacobook/controller/ClacoBookController.java @@ -0,0 +1,76 @@ +package com.curateme.claco.clacobook.controller; + +import io.swagger.v3.oas.annotations.Operation; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.curateme.claco.clacobook.domain.dto.request.UpdateClacoBookRequest; +import com.curateme.claco.clacobook.domain.dto.response.ClacoBookListResponse; +import com.curateme.claco.clacobook.domain.dto.response.ClacoBookResponse; +import com.curateme.claco.clacobook.service.ClacoBookServiceImpl; +import com.curateme.claco.global.annotation.TokenRefreshedCheck; +import com.curateme.claco.global.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@TokenRefreshedCheck +@RestController +@RequestMapping("/api/claco-books") +@RequiredArgsConstructor +public class ClacoBookController { + + private final ClacoBookServiceImpl clacoBookService; + + @PostMapping + @Operation(summary = "클라코북 생성", description = "클라코북 생성") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "사용자를 찾을 수 없음(잘못된 토큰 정보)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "CLB-010", description = "생성 한도 초과 = 5개 이상") + }) + public ApiResponse createClacoBook(@RequestBody UpdateClacoBookRequest request) { + return ApiResponse.ok(clacoBookService.createClacoBook(request)); + } + + @GetMapping + @Operation(summary = "접근 사용자의 클라코북 리스트 조회", description = "접근한 사용자의 클라코북 리스트 조회") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "사용자를 찾을 수 없음(잘못된 토큰 정보)") + }) + public ApiResponse readClacoBookListWithOwner() { + return ApiResponse.ok(new ClacoBookListResponse(clacoBookService.readClacoBooks())); + } + + @PutMapping + @Operation(summary = "클라코북 id 기반 수정", description = "클라코북의 이름과 색을 수정") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "CLB-001", description = "id에 해당하는 클라코북 찾을 수 없음") + }) + public ApiResponse updateClacoBook(@RequestBody UpdateClacoBookRequest request) { + return ApiResponse.ok(clacoBookService.updateClacoBook(request)); + } + + @DeleteMapping("/claco-book/{bookId}") + @Operation(summary = "클라코북 삭제", description = "클라코북 삭제, 소유주가 아니라면 삭제 불가") + @Parameter(description = "삭제하고자 하는 클라코북 Id", example = "1") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "CLB-001", description = "id에 해당하는 클라코북 찾을 수 없음, 혹은 소유주 아님") + }) + public ApiResponse deleteClacoBook(@PathVariable Long bookId) { + clacoBookService.deleteClacoBook(bookId); + return ApiResponse.ok(); + } + +} diff --git a/src/main/java/com/curateme/claco/clacobook/domain/dto/request/UpdateClacoBookRequest.java b/src/main/java/com/curateme/claco/clacobook/domain/dto/request/UpdateClacoBookRequest.java new file mode 100644 index 00000000..e514cda1 --- /dev/null +++ b/src/main/java/com/curateme/claco/clacobook/domain/dto/request/UpdateClacoBookRequest.java @@ -0,0 +1,37 @@ +package com.curateme.claco.clacobook.domain.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.10.24 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.24 이 건 최초 생성 + * 2024.11.05 이 건 Swagger 적용 및 제약 조건 해제 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UpdateClacoBookRequest { + // id + @Schema(description = "클라코북 id, create 시 null로 줘도 됨", example = "0") + private Long id; + // 제목 + @Schema(description = "클라코북 이름", example = "발레 공연 아카이빙") + private String title; + // 책 컬러 + @Schema(description = "클라코북 색상", example = "#5B5B5B") + private String color; + + +} diff --git a/src/main/java/com/curateme/claco/clacobook/domain/dto/response/ClacoBookListResponse.java b/src/main/java/com/curateme/claco/clacobook/domain/dto/response/ClacoBookListResponse.java new file mode 100644 index 00000000..2fe35484 --- /dev/null +++ b/src/main/java/com/curateme/claco/clacobook/domain/dto/response/ClacoBookListResponse.java @@ -0,0 +1,28 @@ +package com.curateme.claco.clacobook.domain.dto.response; + +import java.util.List; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.05 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.05 이 건 Swagger 적용 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ClacoBookListResponse { + + private List clacoBookList; + +} diff --git a/src/main/java/com/curateme/claco/clacobook/domain/dto/response/ClacoBookResponse.java b/src/main/java/com/curateme/claco/clacobook/domain/dto/response/ClacoBookResponse.java new file mode 100644 index 00000000..ee4c5fa7 --- /dev/null +++ b/src/main/java/com/curateme/claco/clacobook/domain/dto/response/ClacoBookResponse.java @@ -0,0 +1,45 @@ +package com.curateme.claco.clacobook.domain.dto.response; + +import com.curateme.claco.clacobook.domain.entity.ClacoBook; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.10.24 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.24 이 건 최초 생성 + * 2024.11.05 이 건 Swagger 적용 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ClacoBookResponse { + // claco book id + @NotNull + @Schema(description = "클라코 북 id", example = "1") + private Long id; + // 제목 + @NotNull + @Schema(description = "클라코 북 제목", example = "OO님의 이야기") + private String title; + // 책 색깔 + @NotNull + @Schema(description = "클라코북 색상", example = "#000000") + private String color; + + public static ClacoBookResponse fromEntity(ClacoBook clacoBook) { + return new ClacoBookResponse(clacoBook.getId(), clacoBook.getTitle(), clacoBook.getColor()); + } + +} diff --git a/src/main/java/com/curateme/claco/clacobook/domain/entity/ClacoBook.java b/src/main/java/com/curateme/claco/clacobook/domain/entity/ClacoBook.java new file mode 100644 index 00000000..be0da3ae --- /dev/null +++ b/src/main/java/com/curateme/claco/clacobook/domain/entity/ClacoBook.java @@ -0,0 +1,96 @@ +package com.curateme.claco.clacobook.domain.entity; + +import java.util.HashSet; +import java.util.Set; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.global.entity.BaseEntity; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.review.domain.entity.TicketReview; + +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.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.10.23 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.23 이 건 최초 생성 + * 2024.10.24 이 건 Member 연관관계 편의 메서드 추가 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE claco_book SET active_status = 'DELETED' WHERE claco_book_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class ClacoBook extends BaseEntity { + + @Id @Column(name = "claco_book_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + // 제목 + @NotNull + private String title; + // 클라코북 색깔 + @NotNull + private String color; + + // Member 다대일 양방향 매핑 (주 테이블) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + // TickerReview 일대다 양방향 매핑 + @Builder.Default + @OneToMany(mappedBy = "clacoBook", orphanRemoval = true, fetch = FetchType.LAZY, cascade = CascadeType.ALL) + private Set ticketReviews = new HashSet<>(); + + public void updateTitle(String title) { + this.title = title; + } + + public void updateColor(String color) { + this.color = color; + } + + // 연관관계 편의 메서드 + public void updateMember(Member member) { + if (this.member != member) { + this.member = member; + } + if (!member.getClacoBooks().contains(this)) { + member.addClacoBook(this); + } + } + + // TickerReview 연관관계 편의 메서드 + public void addTicketReview(TicketReview ticketReview) { + if (!this.ticketReviews.contains(ticketReview)) { + this.ticketReviews.add(ticketReview); + } + if (ticketReview.getClacoBook() != this) { + ticketReview.updateClacoBook(this); + } + } +} diff --git a/src/main/java/com/curateme/claco/clacobook/repository/ClacoBookRepository.java b/src/main/java/com/curateme/claco/clacobook/repository/ClacoBookRepository.java new file mode 100644 index 00000000..b7347741 --- /dev/null +++ b/src/main/java/com/curateme/claco/clacobook/repository/ClacoBookRepository.java @@ -0,0 +1,51 @@ +package com.curateme.claco.clacobook.repository; + +import com.curateme.claco.review.domain.entity.TicketReview; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * @author : 이 건 + * @date : 2024.10.24 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.24 이 건 최초 생성 + * 2024.11.04 이 건 티켓 리뷰 조인 조회 기능 추가 + */ +public interface ClacoBookRepository extends JpaRepository { + + /** + * member 엔티티와 함께 claco book id로 찾는 메서드 + * @param id : 찾고자 하는 claco book id + * @return : Optional ClacoBook + */ + @EntityGraph(attributePaths = {"member"}) + Optional findClacoBookById(Long id); + + /** + * TicketReview 엔티티 조인 조회 메서드 + * @param id : 찾고자 하는 ClacoBook id + * @return : Optional ClacoBook + */ + @EntityGraph(attributePaths = {"ticketReviews"}) + Optional findByIdIs(Long id); + + @EntityGraph(attributePaths = {"member", "ticketReviews"}) + @Query("SELECT c FROM ClacoBook c WHERE c.member.id = :memberId") + List findByMemberId(@Param("memberId") Long memberId); + + @Query("SELECT tr FROM TicketReview tr WHERE tr.clacoBook.id = :clacoBookId ORDER BY function('RAND')") + List findRandomTicketReviewsByClacoBookId(@Param("clacoBookId") Long clacoBookId); + + @Query("SELECT c FROM ClacoBook c " + "WHERE SIZE(c.ticketReviews) >= 3 " + "ORDER BY function('RAND')") + Optional findRandomClacoBookWithThreeOrMoreReviews(); +} diff --git a/src/main/java/com/curateme/claco/clacobook/service/ClacoBookServiceImpl.java b/src/main/java/com/curateme/claco/clacobook/service/ClacoBookServiceImpl.java new file mode 100644 index 00000000..bb2989be --- /dev/null +++ b/src/main/java/com/curateme/claco/clacobook/service/ClacoBookServiceImpl.java @@ -0,0 +1,90 @@ +package com.curateme.claco.clacobook.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.clacobook.domain.dto.request.UpdateClacoBookRequest; +import com.curateme.claco.clacobook.domain.dto.response.ClacoBookResponse; +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.clacobook.repository.ClacoBookRepository; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class ClacoBookServiceImpl { + + private final ClacoBookRepository clacoBookRepository; + private final MemberRepository memberRepository; + private final SecurityContextUtil securityContextUtil; + + public ClacoBookResponse createClacoBook(UpdateClacoBookRequest request) { + // 접근 사용자의 ClacoBook 생성 + Member member = memberRepository.findMemberByIdWithClacoBook( + securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + if (member.getClacoBooks().size() >= 5) { + throw new BusinessException(ApiStatus.CLACO_BOOK_CREATION_LIMIT); + } + + ClacoBook clacoBook = ClacoBook.builder() + .member(member) + .title(request.getTitle() != null ? request.getTitle() : member.getNickname() + "님의 이야기") + .color(request.getColor() != null ? request.getColor() : "#5B120B") + .build(); + + return ClacoBookResponse.fromEntity(clacoBookRepository.save(clacoBook)); + } + + public List readClacoBooks() { + // 접근 사용자의 ClacoBook 조회 + Member member = memberRepository.findMemberByIdWithClacoBook( + securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + return member.getClacoBooks().stream() + .map(ClacoBookResponse::fromEntity) + .toList(); + } + + public ClacoBookResponse updateClacoBook(UpdateClacoBookRequest updateRequest) { + // 소유주 검사 + Long contextMemberId = securityContextUtil.getContextMemberInfo().getMemberId(); + + ClacoBook clacoBook = clacoBookRepository.findClacoBookById(updateRequest.getId()).stream() + .filter(clacoBook1 -> clacoBook1.getMember().getId().equals(contextMemberId)) + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.CLACO_BOOK_NOT_FOUND)); + + // 수정 + clacoBook.updateTitle(updateRequest.getTitle() == null ? clacoBook.getTitle() : updateRequest.getTitle()); + clacoBook.updateColor(updateRequest.getColor() == null ? clacoBook.getColor() : updateRequest.getColor()); + + return ClacoBookResponse.fromEntity(clacoBook); + } + + public void deleteClacoBook(Long bookId) { + // 소유주 검사 + Long contextMemberId = securityContextUtil.getContextMemberInfo().getMemberId(); + + ClacoBook deleteClacoBook = clacoBookRepository.findClacoBookById(bookId).stream() + .filter(clacoBook -> clacoBook.getMember().getId().equals(contextMemberId)) + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.CLACO_BOOK_NOT_FOUND)); + // 삭제 + clacoBookRepository.delete(deleteClacoBook); + } +} diff --git a/src/main/java/com/curateme/claco/concert/controller/ConcertController.java b/src/main/java/com/curateme/claco/concert/controller/ConcertController.java new file mode 100644 index 00000000..7af798ca --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/controller/ConcertController.java @@ -0,0 +1,145 @@ +package com.curateme.claco.concert.controller; + +import com.curateme.claco.concert.domain.dto.response.ConcertAutoCompleteResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertDetailResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertLikedResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertResponseV2; +import com.curateme.claco.concert.service.ConcertService; +import com.curateme.claco.global.annotation.TokenRefreshedCheck; +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.global.response.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@TokenRefreshedCheck +@RestController +@RequestMapping("/api/concerts") +@RequiredArgsConstructor + public class ConcertController { + private final ConcertService concertService; + + @GetMapping("/views") + @Operation(summary = "공연 둘러보기", description = "기능명세서 화면번호 4.0.0") + @Parameter(name = "genre", description = "장르명", example = "웅장한") + @Parameter(name = "direction", description = "정렬 순서", example = "asc/dsc") + public ApiResponse> getConcerts( + @RequestParam(value = "genre", defaultValue = "all") String genre, + @RequestParam(value = "direction", defaultValue = "asc") String direction, + @RequestParam("page") int page, + @RequestParam(value = "size", defaultValue = "9") int size) { + + Pageable pageable = PageRequest.of(page - 1, size); + return ApiResponse.ok(concertService.getConcertInfos(genre, direction, pageable)); + } + + @GetMapping("/filters") + @Operation(summary = "공연 둘러보기 세부사항 필터", description = "기능명세서 화면번호 4.0.1") + @Parameter(name = "direction", description = "정렬 순서", example = "asc/dsc") + @Parameter(name = "area", description = "지역", example = "서울특별시/경기도") + @Parameter(name = "startDate", description = "시작 날짜", example = "yyyy.MM.dd") + @Parameter(name = "endDate", description = "끝나는 날짜", example = "yyyy.MM.dd") + @Parameter(name = "categories", description = "공연 성격 리스트", example = "웅장한, 현대적인(최대 5개)") + public ApiResponse> filterConcerts( + @RequestParam(value = "minPrice", defaultValue = "0") Double minPrice, + @RequestParam(value = "maxPrice", defaultValue = "10000000") Double maxPrice, + @RequestParam(value = "area", required = false) List area, + @RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy.MM.dd") LocalDate startDate, + @RequestParam(value = "endDate", required = false, defaultValue = "9999.12.31") @DateTimeFormat(pattern = "yyyy.MM.dd") LocalDate endDate, + @RequestParam(value = "direction", defaultValue = "asc") String direction, + @RequestParam(value = "page") int page, + @RequestParam(value = "size", defaultValue = "9") int size, + @RequestParam(value = "categories", defaultValue = "웅장한,섬세한,고전적인,현대적인,서정적인,역동적인,낭만적인,비극적인,친숙한,새로운" + ) List categories) + { + Pageable pageable = PageRequest.of(page - 1, size); + + if (startDate == null) { + startDate = LocalDate.now(); + } + + if (area == null || area.isEmpty()) { + area = null; + } + return ApiResponse.ok(concertService.getConcertInfosWithFilter(minPrice, maxPrice, area, startDate, endDate, direction, categories, pageable)); + } + + + @GetMapping("/queries") + @Operation(summary = "공연 둘러보기 검색하기", description = "기능명세서 화면번호 4.1.0") + @Parameter(name = "direction", description = "정렬 순서", example = "asc/dsc") + @Parameter(name = "query", description = "검색어", required = true) + public ApiResponse> searchConcerts( + @RequestParam("query") String query, + @RequestParam(value = "direction", defaultValue = "asc") String direction, + @RequestParam("page") int page, + @RequestParam(value = "size", defaultValue = "9") int size) { + + Pageable pageable = PageRequest.of(page - 1, size); + return ApiResponse.ok(concertService.getSearchConcert(query,direction, pageable)); + } + + @GetMapping("/details/{concertId}") + @Operation(summary = "공연 상세보기", description = "기능명세서 화면번호 3.0.0") + public ApiResponse getConcertDetails( + @PathVariable("concertId") Long concertId + ) { + return ApiResponse.ok(concertService.getConcertDetailWithCategories(concertId)); + } + + @PostMapping("/likes/{concertId}") + @Operation(summary = "공연 좋아요", description = "특정 공연에 좋아요를 추가합니다") + public ApiResponse postLikes( + @PathVariable("concertId") Long concertId + ) { + return ApiResponse.ok(concertService.postLikes(concertId)); + } + + @GetMapping("/likes") + @Operation(summary = "내가 좋아요한 공연", description = "기능명세서 화면번호 7.3.0") + @Parameter(name = "query", description = "검색어") + public ApiResponse> getMyConcerts( + @RequestParam(value = "query", required = false, defaultValue = "all") String query, + @RequestParam(value = "genre", required = false, defaultValue = "all") String genre + ) { + return ApiResponse.ok(concertService.getLikedConcert(query, genre)); + } + + + @GetMapping("/search") + @Operation(summary = "자동완성 API", description = "자동완성 기능으로 10개의 공연을 반환") + public ApiResponse> autoCompletes( + @RequestParam("query") String query + ){ + return ApiResponse.ok(concertService.getAutoComplete(query)); + } + + @GetMapping("/reviews/search") + @Operation(summary = "자동완성 API-리뷰버전", description = "자동완성 기능으로 10개의 공연을 반환") + public ApiResponse> autoCompletesV2( + @RequestParam("query") String query + ){ + return ApiResponse.ok(concertService.getAutoCompleteV2(query)); + } + + @GetMapping("/posters") + @Operation(summary = "KOPIS Poster", description = "Kopis API Poster 다운로드") + public ApiResponse getPosters( + @RequestParam("KopisURL") String KopisURL + ){ + return ApiResponse.ok(concertService.getS3PosterUrl(KopisURL)); + } +} + diff --git a/src/main/java/com/curateme/claco/concert/domain/dto/request/ConcertLikesRequest.java b/src/main/java/com/curateme/claco/concert/domain/dto/request/ConcertLikesRequest.java new file mode 100644 index 00000000..a10fad2b --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/dto/request/ConcertLikesRequest.java @@ -0,0 +1,30 @@ +package com.curateme.claco.concert.domain.dto.request; + +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.concert.domain.entity.ConcertLike; +import com.curateme.claco.member.domain.entity.Member; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ConcertLikesRequest { + + @Schema(description = "멤버 아이디") + private Long memberId; + + @Schema(description = "공연 아이디") + private Long concertId; + + public ConcertLike toEntity(Member member, Concert concert) { + return ConcertLike.builder() + .member(member) + .concert(concert) + .build(); + } +} diff --git a/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertAutoCompleteResponse.java b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertAutoCompleteResponse.java new file mode 100644 index 00000000..a78760ed --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertAutoCompleteResponse.java @@ -0,0 +1,42 @@ +package com.curateme.claco.concert.domain.dto.response; + +import com.curateme.claco.concert.domain.entity.Concert; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ConcertAutoCompleteResponse { + @NotNull + private Long id; + + @NotNull + @Schema(description = "공연 아이디", example = "PF121682") + private String mt20id; + + @NotNull + @Schema(description = "공연 제목", example = "옥탑방 고양이 [대학로]") + private String prfnm; + + @Schema(description = "공연 시작날짜", example = "2010-04-06") + private LocalDate prfpdfrom; + + @Schema(description = "공연 종료날짜", example = "2024-11-30") + private LocalDate prfpdto; + + @Schema(description = "공연 장르", example = "연극") + private String genrenm; + + public static ConcertAutoCompleteResponse fromEntity(Concert concert){ + return new ConcertAutoCompleteResponse(concert.getId(), concert.getMt20id(), concert.getPrfnm(), + concert.getPrfpdfrom(), concert.getPrfpdto(), concert.getGenrenm()); + } +} diff --git a/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertCategoryResponse.java b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertCategoryResponse.java new file mode 100644 index 00000000..37ea5710 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertCategoryResponse.java @@ -0,0 +1,19 @@ +package com.curateme.claco.concert.domain.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ConcertCategoryResponse { + + @Schema(description = "공연 성격") + private String category; + + @Schema(description = "성격 이미지 URL") + private String imageURL; +} diff --git a/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertClacoBookResponse.java b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertClacoBookResponse.java new file mode 100644 index 00000000..dee8ceb5 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertClacoBookResponse.java @@ -0,0 +1,38 @@ +package com.curateme.claco.concert.domain.dto.response; + +import com.curateme.claco.concert.domain.entity.Concert; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ConcertClacoBookResponse { + + @NotNull + @Schema(description = "공연 이름") + private String prfnm; + + @Schema(description = "포스터 URL") + private String poster; + + @Schema(description = "공연 장소") + private String fcltynm; + + @Schema(description = "공연 성격 리스트") + private List categories; + + public static ConcertClacoBookResponse fromEntity( + Concert concert, List categories + ) { + return new ConcertClacoBookResponse(concert.getPrfnm(), concert.getPoster(), + concert.getFcltynm(), categories); + } +} diff --git a/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertDetailResponse.java b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertDetailResponse.java new file mode 100644 index 00000000..edb331e9 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertDetailResponse.java @@ -0,0 +1,123 @@ +package com.curateme.claco.concert.domain.dto.response; + +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.review.domain.dto.response.TicketReviewSimpleResponse; +import com.curateme.claco.review.domain.entity.TicketReview; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Column; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ConcertDetailResponse { + @NotNull + private Long id; + + @NotNull + @Schema(description = "공연 아이디", example = "PF121682") + private String mt20id; + + @NotNull + @Schema(description = "공연 제목", example = "옥탑방 고양이 [대학로]") + private String prfnm; + + @Schema(description = "공연 시작날짜", example = "2010-04-06") + private LocalDate prfpdfrom; + + @Schema(description = "공연 종료날짜", example = "2024-11-30") + private LocalDate prfpdto; + + @Schema(description = "공연 장소", example = "틴틴홀") + private String fcltynm; + + @Schema(description = "공연 포스터 URL", example = "http://www.kopis.or.kr/upload/pfmPoster/PF_PF121682_210322_143051.gif") + private String poster; + + @Schema(description = "공연 지역", example = "서울특별시") + private String area; + + @Schema(description = "공연 장르", example = "연극") + private String genrenm; + + @Schema(description = "오픈런 여부", example = "Y") + private String openrun; + + @Schema(description = "공연 상태", example = "공연중") + private String prfstate; + + @Schema(description = "공연 캐스팅", example = "정태령, 유다영, 서해든, 민채우, 가은, 이른봄, 정진혁 등") + private String prfcast; + + @Schema(description = "공연 시간", example = "1시간 40분") + private String prfruntime; + + @Schema(description = "공연 관람 나이", example = "만 13세 이상") + private String prfage; + + @Schema(description = "자리별 가격", example = "전석 40,000원") + private String pcseguidance; + + @Schema(description = "업데이트 날짜", example = "2024-10-24 11:01:03") + private String updatedate; + + @Schema(description = "공연 요일 및 시간대", example = "월요일 ~ 목요일(15:00,16:00,17:15,19:30), 토요일 ~ 일요일(11:50,12:50,14:00,15:00,16:15,17:15,18:30,19:30,20:30), HOL(11:50,12:00,12:50,14:00,14:10,15:00,16:15,16:20,17:15,18:30,19:30,20:30), 금요일(15:00,16:00,17:15,19:00,19:30)") + private String dtguidance; + + @Schema(description = "공연 소개 URL", example = "http://www.kopis.or.kr/upload/pfmIntroImage/PF_PF121682_240913_0959491.jpg") + private String styurl; + + @Schema(description = "티켓 리뷰 리스트", example = "[...]") + private List ticketReviewSimpleResponses; + + @Schema(name = "공연 요약 정보", example = "...") + private String summary; + + @Schema(name = "공연 좋아요 여부") + private boolean liked; + + @Schema(description = "공연 성격 리스트", example = "[...]") + private List categories; + + + public static ConcertDetailResponse fromEntity(Concert concert, List ticketReviewSimpleResponses, List categories, boolean liked){ + return ConcertDetailResponse.builder() + .id(concert.getId()) + .mt20id(concert.getMt20id()) + .prfnm(concert.getPrfnm()) + .prfpdfrom(concert.getPrfpdfrom()) + .prfpdto(concert.getPrfpdto()) + .fcltynm(concert.getFcltynm()) + .poster(concert.getPoster()) + .area(concert.getArea()) + .genrenm(concert.getGenrenm()) + .openrun(concert.getOpenrun()) + .prfstate(concert.getPrfstate()) + .prfcast(concert.getPrfcast()) + .prfruntime(concert.getPrfruntime()) + .prfage(concert.getPrfage()) + .pcseguidance(concert.getPcseguidance()) + .updatedate(concert.getUpdatedate()) + .dtguidance(concert.getDtguidance()) + .styurl(concert.getStyurl()) + .ticketReviewSimpleResponses(ticketReviewSimpleResponses) + .summary(concert.getSummary()) + .liked(liked) + .categories(categories) + .build(); + } + + + public void setCategories(List categories) { + this.categories = categories; + } + +} diff --git a/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertLikedResponse.java b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertLikedResponse.java new file mode 100644 index 00000000..8b5dd1b7 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertLikedResponse.java @@ -0,0 +1,40 @@ +package com.curateme.claco.concert.domain.dto.response; + +import com.curateme.claco.concert.domain.entity.Concert; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ConcertLikedResponse { + + @NotNull + private Long id; + + @NotNull + @Schema(description = "공연 제목") + private String prfnm; + + @Schema(description = "공연 장르") + private String genrenm; + + @Schema(description = "공연 포스터 URL") + private String poster; + + @Schema(description = "공연 성격 리스트") + private List categories; + + public static ConcertLikedResponse fromEntity(Concert concert, List categories){ + return new ConcertLikedResponse( + concert.getId(), concert.getPrfnm(), concert.getGenrenm(), concert.getPoster(), categories + ); + } +} diff --git a/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertResponse.java b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertResponse.java new file mode 100644 index 00000000..cecc3ea2 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertResponse.java @@ -0,0 +1,84 @@ +package com.curateme.claco.concert.domain.dto.response; + +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertsResponseV1; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ConcertResponse { + + @NotNull + private Long id; + + @NotNull + @Schema(description = "공연 아이디", example = "PF121682") + private String mt20id; + + @NotNull + @Schema(description = "공연 제목", example = "옥탑방 고양이 [대학로]") + private String prfnm; + + @Schema(description = "공연 시작날짜", example = "2010-04-06") + private LocalDate prfpdfrom; + + @Schema(description = "공연 종료날짜", example = "2024-11-30") + private LocalDate prfpdto; + + @Schema(description = "공연 장소", example = "틴틴홀") + private String fcltynm; + + @Schema(description = "공연 포스터 URL", example = "http://www.kopis.or.kr/upload/pfmPoster/PF_PF121682_210322_143051.gif") + private String poster; + + @Schema(description = "공연 장르", example = "연극") + private String genrenm; + + @Schema(description = "공연 상태", example = "공연중") + private String prfstate; + + @Schema(description = "공연 성격 리스트") + private List categories; + + @Schema(description = "추천 공연 리스트") + private List recommendationConcertsResponseV1s; + + @Schema(name = "공연 좋아요 여부") + private Boolean liked; + + public void setLiked(boolean liked) { + this.liked = liked; + } + + public static ConcertResponse fromEntity(Concert concert, List categories, List recommendationConcertsResponseV1s) { + return ConcertResponse.builder() + .id(concert != null ? concert.getId() : null) + .mt20id(concert != null ? concert.getMt20id() : null) + .prfnm(concert != null ? concert.getPrfnm() : null) + .prfpdfrom(concert != null ? concert.getPrfpdfrom() : null) + .prfpdto(concert != null ? concert.getPrfpdto() : null) + .fcltynm(concert != null ? concert.getFcltynm() : null) + .poster(concert != null ? concert.getPoster() : null) + .genrenm(concert != null ? concert.getGenrenm() : null) + .prfstate(concert != null ? concert.getPrfstate() : null) + .categories(categories) + .recommendationConcertsResponseV1s(recommendationConcertsResponseV1s) + .build(); + } + + public static ConcertResponse fromRecommendations(List recommendationConcertsResponseV1s) { + return ConcertResponse.builder() + .recommendationConcertsResponseV1s(recommendationConcertsResponseV1s) + .build(); + } +} diff --git a/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertResponseV2.java b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertResponseV2.java new file mode 100644 index 00000000..d0fa5491 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/dto/response/ConcertResponseV2.java @@ -0,0 +1,58 @@ +package com.curateme.claco.concert.domain.dto.response; + +import com.curateme.claco.concert.domain.entity.Concert; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ConcertResponseV2 { + @NotNull + private Long id; + + @NotNull + @Schema(description = "공연 아이디", example = "PF121682") + private String mt20id; + + @NotNull + @Schema(description = "공연 제목", example = "옥탑방 고양이 [대학로]") + private String prfnm; + + @Schema(description = "공연 시작날짜", example = "2010-04-06") + private LocalDate prfpdfrom; + + @Schema(description = "공연 종료날짜", example = "2024-11-30") + private LocalDate prfpdto; + + @Schema(description = "공연 장르", example = "연극") + private String genrenm; + + @Schema(description = "공연 상태", example = "공연 예정") + private String prfstate; + + @Schema(description = "공연 포스터") + private String poster; + + @Schema(description = "공연 시설") + private String fcltynm; + + private List categories; + + @Schema(name = "공연 좋아요 여부", description = "항상 true로 반환") + private Boolean liked; + + public static ConcertResponseV2 fromEntity(Concert concert, List categories){ + return new ConcertResponseV2(concert.getId(), concert.getMt20id(), concert.getPrfnm(), + concert.getPrfpdfrom(), concert.getPrfpdto(), concert.getGenrenm(), concert.getPrfstate(), concert.getPoster(), + concert.getFcltynm(), categories, true); + } +} diff --git a/src/main/java/com/curateme/claco/concert/domain/entity/Category.java b/src/main/java/com/curateme/claco/concert/domain/entity/Category.java new file mode 100644 index 00000000..e7960dec --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/entity/Category.java @@ -0,0 +1,26 @@ +package com.curateme.claco.concert.domain.entity; + +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 +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Category { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String category; + + private String imageUrl; +} diff --git a/src/main/java/com/curateme/claco/concert/domain/entity/Concert.java b/src/main/java/com/curateme/claco/concert/domain/entity/Concert.java new file mode 100644 index 00000000..5fc9d947 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/entity/Concert.java @@ -0,0 +1,137 @@ +package com.curateme.claco.concert.domain.entity; + +import com.curateme.claco.global.entity.BaseEntity; +import com.curateme.claco.review.domain.entity.TicketReview; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +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.MapKeyColumn; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import jakarta.persistence.OneToMany; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Concert extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "concert_id") + private String mt20id; + + @Column(name = "concert_name") + private String prfnm; + + @Column(name = "start_date") + private LocalDate prfpdfrom; + + @Column(name = "end_date") + private LocalDate prfpdto; + + @Column(name = "facility_name") + private String fcltynm; + + @Column(name = "poster") + private String poster; + + @Column(name = "area") + private String area; + + @Column(name = "genre") + private String genrenm; + + @Column(name = "openrun") + private String openrun; + + @Column(name = "status") + private String prfstate; + + @Column(name = "cast") + private String prfcast; + + @Column(name = "crew") + private String prfcrew; + + @Column(name = "runtime") + private String prfruntime; + + @Column(name = "age") + private String prfage; + + @Column(name = "company_name") + private String entrpsnm; + + @Column(name = "company_namep") + private String entrpsnmP; + + @Column(name = "company_namea") + private String entrpsnmA; + + @Column(name = "company_nameh") + private String entrpsnmH; + + @Column(name = "company_names") + private String entrpsnmS; + + @Column(name = "seat_guidance") + private String pcseguidance; + + @Column(name = "visit") + private String visit; + + @Column(name = "child") + private String child; + + @Column(name = "daehakro") + private String daehakro; + + @Column(name = "festival") + private String festival; + + @Column(name = "musical_license") + private String musicallicense; + + @Column(name = "musical_create") + private String musicalcreate; + + @Column(name = "update_date") + private String updatedate; + + @Column(name = "schedule_guidance", length = 1000) + private String dtguidance; + + @Column(name = "introduction") + private String styurl; + + @Column(name = "summary", length = 1500) + private String summary; + + @OneToMany(mappedBy = "concert", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List ticketReview; + + @ElementCollection + @CollectionTable(name = "concert_category", joinColumns = @JoinColumn(name = "concert_id")) + @MapKeyColumn(name = "category") + @Column(name = "score") + private Map categories; +} diff --git a/src/main/java/com/curateme/claco/concert/domain/entity/ConcertCategory.java b/src/main/java/com/curateme/claco/concert/domain/entity/ConcertCategory.java new file mode 100644 index 00000000..3917649f --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/entity/ConcertCategory.java @@ -0,0 +1,34 @@ +package com.curateme.claco.concert.domain.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "concert_category") +@Builder +public class ConcertCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "score", nullable = false) + private Double score; + + @ManyToOne + @JoinColumn(name = "concert_id", nullable = false) + private Concert concert; + + @ManyToOne + @JoinColumn(name = "category_id", nullable = false) + private Category category; +} + + diff --git a/src/main/java/com/curateme/claco/concert/domain/entity/ConcertLike.java b/src/main/java/com/curateme/claco/concert/domain/entity/ConcertLike.java new file mode 100644 index 00000000..d845112c --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/domain/entity/ConcertLike.java @@ -0,0 +1,37 @@ +package com.curateme.claco.concert.domain.entity; + +import com.curateme.claco.global.entity.BaseEntity; +import com.curateme.claco.member.domain.entity.Member; +import jakarta.persistence.Entity; +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 +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "concert_like") +public class ConcertLike extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne + @JoinColumn(name = "concert_id", nullable = false) + private Concert concert; +} diff --git a/src/main/java/com/curateme/claco/concert/repository/CategoryRepository.java b/src/main/java/com/curateme/claco/concert/repository/CategoryRepository.java new file mode 100644 index 00000000..255a1450 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/repository/CategoryRepository.java @@ -0,0 +1,8 @@ +package com.curateme.claco.concert.repository; + +import com.curateme.claco.concert.domain.entity.Category; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryRepository extends JpaRepository { + +} diff --git a/src/main/java/com/curateme/claco/concert/repository/ConcertCategoryRepository.java b/src/main/java/com/curateme/claco/concert/repository/ConcertCategoryRepository.java new file mode 100644 index 00000000..5b26a958 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/repository/ConcertCategoryRepository.java @@ -0,0 +1,16 @@ +package com.curateme.claco.concert.repository; + +import com.curateme.claco.concert.domain.entity.ConcertCategory; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ConcertCategoryRepository extends JpaRepository { + @Query("SELECT cc.category.id FROM ConcertCategory cc WHERE cc.concert.id = :concertId") + List findCategoryIdsByCategoryName(@Param("concertId") Long concertId); + + @Query("SELECT cc.category.category FROM ConcertCategory cc WHERE cc.concert.id = :concertId") + List findCategoryNamesByConcertId(@Param("concertId") Long concertId); +} + diff --git a/src/main/java/com/curateme/claco/concert/repository/ConcertLikeRepository.java b/src/main/java/com/curateme/claco/concert/repository/ConcertLikeRepository.java new file mode 100644 index 00000000..a86dd485 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/repository/ConcertLikeRepository.java @@ -0,0 +1,37 @@ +package com.curateme.claco.concert.repository; + +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.concert.domain.entity.ConcertLike; +import com.curateme.claco.member.domain.entity.Member; +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.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ConcertLikeRepository extends JpaRepository { + + @Query("SELECT cl FROM ConcertLike cl WHERE cl.member = :member AND cl.concert = :concert") + Optional findByMemberAndConcert(@Param("member") Member member, @Param("concert") Concert concert); + + @Query("SELECT CASE WHEN COUNT(cl) > 0 THEN true ELSE false END FROM ConcertLike cl WHERE cl.concert.id = :concertId") + boolean existsByConcertId(@Param("concertId") Long concertId); + + @Query("SELECT cl.concert.id FROM ConcertLike cl WHERE cl.member.id = :userId ORDER BY cl.createdAt DESC") + Page findMostRecentLikedConcert(@Param("userId") Long userId, Pageable pageable); + + @Query("SELECT cl.concert.id FROM ConcertLike cl WHERE cl.member.id = :userId") + List findConcertIdsByMemberId(@Param("userId") Long userId); + + @Query("SELECT cl.concert.id " + "FROM ConcertLike cl " + "GROUP BY cl.concert.id " + "ORDER BY COUNT(cl) DESC") + List findTopConcertIdsByLikeCount(Pageable pageable); + + @Query("SELECT CASE WHEN COUNT(cl) > 0 THEN true ELSE false END " + + "FROM ConcertLike cl " + + "WHERE cl.concert.id = :concertId AND cl.member.id = :memberId") + boolean existsByConcertIdAndMemberId(@Param("concertId") Long concertId, @Param("memberId") Long memberId); + +} + diff --git a/src/main/java/com/curateme/claco/concert/repository/ConcertRepository.java b/src/main/java/com/curateme/claco/concert/repository/ConcertRepository.java new file mode 100644 index 00000000..10625170 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/repository/ConcertRepository.java @@ -0,0 +1,76 @@ +package com.curateme.claco.concert.repository; + +import com.curateme.claco.concert.domain.entity.Concert; +import java.time.LocalDate; +import java.util.List; +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 org.springframework.security.core.parameters.P; + +public interface ConcertRepository extends JpaRepository { + Page findByIdIn(List ids, Pageable pageable); + + @Query("SELECT DISTINCT c.id FROM Concert c JOIN c.categories cat WHERE c.area = :area AND c.prfpdto BETWEEN :startDate AND :endDate AND EXISTS (SELECT 1 FROM ConcertCategory cc WHERE cc.concert = c AND cc.category.category IN :categories)") + List findConcertIdsByFilters(@Param("area") String area, @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @Param("categories") List categories); + + @Query("SELECT c.id FROM Concert c " + + "WHERE (:query = 'all' OR c.prfnm LIKE %:query% " + + "OR c.prfcast LIKE %:query% " + + "OR c.fcltynm LIKE %:query%) " + + "AND c.prfpdto >= CURRENT_DATE") + List findConcertIdsBySearchQuery(@Param("query") String query); + + @Query("SELECT c.id FROM Concert c " + + "WHERE (:query = 'all' OR c.prfnm LIKE %:query% " + + "OR c.prfcast LIKE %:query% " + + "OR c.fcltynm LIKE %:query%) ") + List findConcertIdsBySearchQueryV2(@Param("query") String query); + + @Query("SELECT c FROM Concert c WHERE c.id = :concertId") + Concert findConcertById(@Param("concertId") Long concertId); + + @Query("SELECT c FROM Concert c " + + "WHERE (c.prfnm LIKE %:query% " + + "OR c.prfcast LIKE %:query% " + + "OR c.fcltynm LIKE %:query%) " + + "AND c.prfpdto >= CURRENT_DATE") + Page findBySearchQuery(@Param("query") String query, Pageable pageable); + + @Query("SELECT c FROM Concert c " + + "LEFT JOIN c.categories cat " + + "WHERE (:area = 'all' OR c.area = :area) " + + "AND c.prfpdto BETWEEN :startDate AND :endDate " + + "AND (:categories IS NULL OR EXISTS (" + + " SELECT 1 FROM ConcertCategory cc " + + " WHERE cc.concert = c AND cc.category.category IN :categories))") + Page findConcertsByFilters( + @Param("area") String area, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("categories") List categories, + Pageable pageable); + + @Query("SELECT c FROM Concert c " + + "LEFT JOIN c.categories cat " + + "WHERE (:area IS NULL OR c.area IN :area) " + + "AND c.prfpdto BETWEEN :startDate AND :endDate " + + "AND (:categories IS NULL OR EXISTS (" + + " SELECT 1 FROM ConcertCategory cc " + + " WHERE cc.concert = c AND cc.category.category IN :categories))") + List findConcertsByFiltersWithoutPaging( + @Param("area") List area, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("categories") List categories); + + + + @Query("SELECT c FROM Concert c " + + "WHERE (:genre = 'all' OR c.genrenm = :genre) " + + "AND c.prfpdto >= CURRENT_DATE") + Page findConcertsByGenreWithPagination(@Param("genre") String genre, Pageable pageable); + +} diff --git a/src/main/java/com/curateme/claco/concert/service/ConcertService.java b/src/main/java/com/curateme/claco/concert/service/ConcertService.java new file mode 100644 index 00000000..baab9df1 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/service/ConcertService.java @@ -0,0 +1,33 @@ +package com.curateme.claco.concert.service; + +import com.curateme.claco.concert.domain.dto.request.ConcertLikesRequest; +import com.curateme.claco.concert.domain.dto.response.ConcertAutoCompleteResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertDetailResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertLikedResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertResponseV2; +import com.curateme.claco.global.response.PageResponse; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Pageable; + +public interface ConcertService { + PageResponse getConcertInfos(String categoryName, String direction, Pageable pageable); + + PageResponse getConcertInfosWithFilter(Double minPrice, Double maxPrice, List area, LocalDate startDate, LocalDate endDate, + String direction, List categories, Pageable pageable); + + PageResponse getSearchConcert(String query, String direction, Pageable pageable); + + ConcertDetailResponse getConcertDetailWithCategories(Long concertId); + + String postLikes(Long concertId); + + List getLikedConcert(String query, String genre); + + List getAutoComplete(String query); + + String getS3PosterUrl(String KopisURL); + + List getAutoCompleteV2(String query); +} diff --git a/src/main/java/com/curateme/claco/concert/service/ConcertServiceImpl.java b/src/main/java/com/curateme/claco/concert/service/ConcertServiceImpl.java new file mode 100644 index 00000000..e9dc3ab9 --- /dev/null +++ b/src/main/java/com/curateme/claco/concert/service/ConcertServiceImpl.java @@ -0,0 +1,358 @@ +package com.curateme.claco.concert.service; + +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.concert.domain.dto.request.ConcertLikesRequest; +import com.curateme.claco.concert.domain.dto.response.ConcertAutoCompleteResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertCategoryResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertDetailResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertLikedResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertResponseV2; +import com.curateme.claco.concert.domain.entity.Category; +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.concert.domain.entity.ConcertLike; +import com.curateme.claco.concert.repository.CategoryRepository; +import com.curateme.claco.concert.repository.ConcertCategoryRepository; +import com.curateme.claco.concert.repository.ConcertLikeRepository; +import com.curateme.claco.concert.repository.ConcertRepository; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.global.response.PageResponse; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertsResponseV1; +import com.curateme.claco.recommendation.service.RecommendationService; +import com.curateme.claco.recommendation.service.RecommendationServiceImpl; +import com.curateme.claco.review.domain.dto.response.TicketReviewSimpleResponse; +import com.curateme.claco.review.domain.entity.TicketReview; +import com.curateme.claco.review.repository.TicketReviewRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +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.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.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class ConcertServiceImpl implements ConcertService { + + private final ConcertRepository concertRepository; + private final ConcertCategoryRepository concertCategoryRepository; + private final CategoryRepository categoryRepository; + private final MemberRepository memberRepository; + private final ConcertLikeRepository concertLikeRepository; + private final SecurityContextUtil securityContextUtil; + private final TicketReviewRepository ticketReviewRepository; + private final RecommendationServiceImpl recommendationServiceImpl; + + @Value("${cloud.ai.url}") + private String URL; + + + @Override + public PageResponse getConcertInfos(String genre, String direction, Pageable pageable) { + + Sort sort = direction.equalsIgnoreCase("asc") ? Sort.by("prfpdfrom").ascending() : Sort.by("prfpdfrom").descending(); + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); + + Page concertPage = concertRepository.findConcertsByGenreWithPagination(genre, sortedPageable); + Long memberId = securityContextUtil.getContextMemberInfo().getMemberId(); + + List concertResponses = concertPage.getContent().stream() + .map(concert -> { + List categoryIds = concertCategoryRepository.findCategoryIdsByCategoryName(concert.getId()); + List categoryList = categoryRepository.findAllById(categoryIds); + + List categoryResponses = categoryList.stream() + .map(category -> new ConcertCategoryResponse(category.getCategory(), category.getImageUrl())) + .collect(Collectors.toList()); + + ConcertResponse response = ConcertResponse.fromEntity(concert, categoryResponses, null); + + // 좋아요 여부 설정 + boolean liked = concertLikeRepository.existsByConcertIdAndMemberId(concert.getId(), memberId); + response.setLiked(liked); + + return response; + }) + .collect(Collectors.toList()); + + return PageResponse.builder() + .listPageResponse(concertResponses) + .totalCount(concertPage.getTotalElements()) + .size(concertPage.getSize()) + .totalPage(concertPage.getTotalPages()) + .currentPage(concertPage.getPageable().getPageNumber()+1) + .build(); + } + + @Override + public PageResponse getConcertInfosWithFilter(Double minPrice, Double maxPrice, + List area, LocalDate startDate, LocalDate endDate, String direction, List categories, Pageable pageable) { + + Comparator comparator = direction.equalsIgnoreCase("asc") + ? Comparator.comparing(Concert::getPrfpdfrom) + : Comparator.comparing(Concert::getPrfpdfrom).reversed(); + + List concertList = new ArrayList<>(concertRepository.findConcertsByFiltersWithoutPaging(area, startDate, endDate, categories)); + + concertList.sort(comparator); + + long totalElements = concertList.size(); + + int start = (int) pageable.getOffset(); + int end = Math.min((start + pageable.getPageSize()), concertList.size()); + int pageSize = pageable.getPageSize(); + int totalPages = (int) Math.ceil((double) totalElements / pageSize); + + List paginatedConcerts = concertList.subList(start, end); + + List concertResponses = paginatedConcerts.stream() + .map(concert -> { + List categoryIds = concertCategoryRepository.findCategoryIdsByCategoryName(concert.getId()); + List categoryList = categoryRepository.findAllById(categoryIds); + + List categoryResponses = categoryList.stream() + .map(category -> new ConcertCategoryResponse(category.getCategory(), category.getImageUrl())) + .collect(Collectors.toList()); + + return ConcertResponse.fromEntity(concert, categoryResponses,null); + }) + .collect(Collectors.toList()); + + // PageResponse 생성 및 반환 + return PageResponse.builder() + .listPageResponse(concertResponses) + .totalCount(totalElements) + .size(pageable.getPageSize()) + .totalPage(totalPages) + .currentPage(pageable.getPageNumber()+1) + .build(); + } + + + @Override + public PageResponse getSearchConcert(String query, String direction, Pageable pageable) { + + Sort sort = direction.equalsIgnoreCase("asc") ? Sort.by("prfpdfrom").ascending() : Sort.by("prfpdfrom").descending(); + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); + + Page concertPage = concertRepository.findBySearchQuery(query, sortedPageable); + + List concertResponses; + + if (concertPage.isEmpty()) { // `concertPage`가 비어 있는 경우 처리 + List recommendationConcertsResponseV1s = recommendationServiceImpl.getConcertRecommendations(3); + concertResponses = List.of(ConcertResponse.fromRecommendations(recommendationConcertsResponseV1s)); + } else { + concertResponses = concertPage.getContent().stream() + .map(concert -> { + List categoryIds = concertCategoryRepository.findCategoryIdsByCategoryName(concert.getId()); + List categories = categoryRepository.findAllById(categoryIds); + + List categoryResponses = categories.stream() + .map(category -> new ConcertCategoryResponse(category.getCategory(), category.getImageUrl())) + .collect(Collectors.toList()); + + return ConcertResponse.fromEntity(concert, categoryResponses, null); + }) + .collect(Collectors.toList()); + } + + // PageResponse 생성 + return PageResponse.builder() + .listPageResponse(concertResponses) + .totalCount(concertPage.getTotalElements()) + .size(concertPage.getSize()) + .totalPage(concertPage.getTotalPages()) + .currentPage(concertPage.getPageable().getPageNumber()+1) + .build(); + } + + @Override + public ConcertDetailResponse getConcertDetailWithCategories(Long concertId) { + + Long memberId = securityContextUtil.getContextMemberInfo().getMemberId(); + + Concert concert = concertRepository.findConcertById(concertId); + + List ticketReviewIds = ticketReviewRepository.findByConcertId(concertId); + + List ticketReviews = ticketReviewRepository.findAllById(ticketReviewIds); + + List ticketReviewResponses = ticketReviews.stream() + .map(TicketReviewSimpleResponse::fromEntity) + .collect(Collectors.toList()); + + List categoryIds = concertCategoryRepository.findCategoryIdsByCategoryName(concertId); + List categories = categoryRepository.findAllById(categoryIds); + + List categoryResponses = categories.stream() + .map(category -> new ConcertCategoryResponse(category.getCategory(), category.getImageUrl())) + .collect(Collectors.toList()); + + boolean liked = concertLikeRepository.existsByConcertIdAndMemberId(concertId, memberId); + + return ConcertDetailResponse.fromEntity(concert, ticketReviewResponses, categoryResponses, liked); + } + + @Override + public String postLikes(Long concertId) { + + Long memberId = securityContextUtil.getContextMemberInfo().getMemberId(); + Member member = memberRepository.getById(memberId); + Concert concert = concertRepository.findById(concertId) + .orElseThrow(() -> new BusinessException(ApiStatus.CONCERT_NOT_FOUND)); + + // 좋아요가 이미 있는지 확인 + Optional existingLike = concertLikeRepository.findByMemberAndConcert(member, concert); + + if (existingLike.isPresent()) { + concertLikeRepository.delete(existingLike.get()); + return "좋아요가 취소되었습니다."; + } else { + ConcertLike concertLike = ConcertLike.builder() + .member(member) + .concert(concert) + .build(); + concertLikeRepository.save(concertLike); + return "좋아요가 등록되었습니다."; + } + } + + @Override + public List getLikedConcert(String query, String genre) { + + Long memberId = securityContextUtil.getContextMemberInfo().getMemberId(); + + List concertLikedIds = concertLikeRepository.findConcertIdsByMemberId(memberId); + + // 필터링 적용 + concertLikedIds = filterConcertsByQueryAndGenre(concertLikedIds, query, genre); + + List likedConcerts = new ArrayList<>(); + + concertLikedIds.forEach(concertId -> { + Concert concert = concertRepository.findConcertById(concertId); + + List categoryIds = concertCategoryRepository.findCategoryIdsByCategoryName(concertId); + List categories = categoryRepository.findAllById(categoryIds); + + List categoryResponses = categories.stream() + .map(category -> new ConcertCategoryResponse(category.getCategory(), category.getImageUrl())) + .collect(Collectors.toList()); + + ConcertResponseV2 response = ConcertResponseV2.fromEntity(concert, categoryResponses); + likedConcerts.add(response); + }); + + + return likedConcerts; + } + + @Override + public List getAutoComplete(String query) { + + List concertIds = concertRepository.findConcertIdsBySearchQuery(query); + List topConcertIds = concertIds.size() > 10 ? concertIds.subList(0, 10) : concertIds; + + List concerts = concertRepository.findAllById(topConcertIds); + + return concerts.stream() + .map(ConcertAutoCompleteResponse::fromEntity) + .toList(); + } + + @Override + public List getAutoCompleteV2(String query) { + + List concertIds = concertRepository.findConcertIdsBySearchQueryV2(query); + List topConcertIds = concertIds.size() > 10 ? concertIds.subList(0, 10) : concertIds; + + List concerts = concertRepository.findAllById(topConcertIds); + + return concerts.stream() + .map(ConcertAutoCompleteResponse::fromEntity) + .toList(); + } + + public String getS3PosterUrl(String KopisURL) { + String urlWithUserId = URL + "/download/posters"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = new HashMap<>(); + body.put("image_url", KopisURL); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + RestTemplate restTemplate = new RestTemplate(); + try { + + ResponseEntity response = restTemplate.exchange(urlWithUserId, HttpMethod.POST, requestEntity, String.class); + if (response.getStatusCode().is2xxSuccessful()) { + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode rootNode = objectMapper.readTree(response.getBody()); + + return rootNode.get("s3_url").asText(); + } else { + return response.getBody(); + } + } catch (Exception e) { + return e.getMessage(); + } + } + + + List filterConcertsByQueryAndGenre(List concertLikedIds, String query, String genre) { + // 검색어로 필터링 + if (query != null && !query.isEmpty()) { + List filteredByQuery = concertRepository.findConcertIdsBySearchQuery(query); + concertLikedIds = concertLikedIds.stream() + .filter(filteredByQuery::contains) + .toList(); + + } + + // 장르로 필터링 + if (!"all".equals(genre) && !genre.isEmpty()) { + concertLikedIds = concertLikedIds.stream() + .filter(concertId -> { + Concert concert = concertRepository.findConcertById(concertId); + return genre.equals(concert.getGenrenm()); + }) + .collect(Collectors.toList()); + } + + return concertLikedIds; + } + + + + +} + diff --git a/src/main/java/com/curateme/claco/global/annotation/RefreshedAspect.java b/src/main/java/com/curateme/claco/global/annotation/RefreshedAspect.java new file mode 100644 index 00000000..6f511526 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/annotation/RefreshedAspect.java @@ -0,0 +1,33 @@ +package com.curateme.claco.global.annotation; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +import com.curateme.claco.authentication.util.RefreshContextHolder; +import com.curateme.claco.global.response.ApiResponse; + +/** + * 토큰 재생성 체크 AOP + */ +@Aspect +@Component +public class RefreshedAspect { + + @Around("@annotation(com.curateme.claco.global.annotation.TokenRefreshedCheck) || @within(com.curateme.claco.global.annotation.TokenRefreshedCheck)") + public Object applyTokenRefreshed(ProceedingJoinPoint joinPoint) throws Throwable { + // 재생성 되었다면 refreshed 필드 변환 + if (RefreshContextHolder.isRefreshed()){ + Object result = joinPoint.proceed(); + + if (result instanceof ApiResponse apiResponse) { + apiResponse.setRefreshed(true); + } + + return result; + } + return joinPoint.proceed(); + } + +} diff --git a/src/main/java/com/curateme/claco/global/annotation/TokenRefreshedCheck.java b/src/main/java/com/curateme/claco/global/annotation/TokenRefreshedCheck.java new file mode 100644 index 00000000..4f9c4d9d --- /dev/null +++ b/src/main/java/com/curateme/claco/global/annotation/TokenRefreshedCheck.java @@ -0,0 +1,14 @@ +package com.curateme.claco.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 토큰이 재생성 되었는지 확인하는 AOP 애노테이션 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface TokenRefreshedCheck { +} diff --git a/src/main/java/com/curateme/claco/global/config/S3Config.java b/src/main/java/com/curateme/claco/global/config/S3Config.java new file mode 100644 index 00000000..4153d125 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/config/S3Config.java @@ -0,0 +1,36 @@ +package com.curateme.claco.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + + @Bean + public AmazonS3 amazonS3Client(){ + AWSCredentials credentials = new BasicAWSCredentials(accessKey,secretKey); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } +} diff --git a/src/main/java/com/curateme/claco/global/config/SecurityConfig.java b/src/main/java/com/curateme/claco/global/config/SecurityConfig.java new file mode 100644 index 00000000..f094ff89 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/config/SecurityConfig.java @@ -0,0 +1,119 @@ +package com.curateme.claco.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +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.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import com.curateme.claco.authentication.filter.JwtAuthenticationFilter; +import com.curateme.claco.authentication.handler.oauth.OAuthLoginFailureHandler; +import com.curateme.claco.authentication.handler.oauth.OAuthLoginSuccessHandler; +import com.curateme.claco.authentication.service.CustomOAuth2UserService; +import com.curateme.claco.authentication.util.JwtTokenUtil; +import com.curateme.claco.global.filter.ExceptionHandlerFilter; +import com.curateme.claco.member.domain.entity.Role; +import com.curateme.claco.member.repository.MemberRepository; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenUtil jwtTokenUtil; + private final MemberRepository memberRepository; + private final OAuthLoginSuccessHandler oAuthLoginSuccessHandler; + private final OAuthLoginFailureHandler oAuthLoginFailureHandler; + private final CustomOAuth2UserService customOAuth2UserService; + private final ObjectMapper objectMapper; + + @Bean + SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + httpSecurity + .formLogin(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .cors((cors) -> + cors.configurationSource(corsConfiguration())) + .headers((headers) -> + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) + ) + .sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests((authorizeHttpRequests) -> + authorizeHttpRequests + .requestMatchers("/health-check", "/oauth2/authorization/kakao", + "/login/oauth2/code/kakao", "/favicon.ico", "/actuator/**") + .permitAll() + .requestMatchers("/swagger-ui/**") + .permitAll() + .requestMatchers("/v3/api-docs/**") + .permitAll() + .requestMatchers("/api/members/check-nickname") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/members") + .hasAnyRole(Role.SOCIAL.getRole(), Role.ADMIN.getRole()) + .requestMatchers("/api/**") + .hasAnyRole(Role.MEMBER.getRole(), Role.ADMIN.getRole()) + .anyRequest() + .authenticated() + ) + .oauth2Login((oauth2Login) -> + oauth2Login.successHandler(oAuthLoginSuccessHandler) + .failureHandler(oAuthLoginFailureHandler) + .userInfoEndpoint((userInfoEndPoint) -> + userInfoEndPoint.userService(customOAuth2UserService) + ) + ); + httpSecurity.addFilterBefore(jwtAuthenticationFilter(), LogoutFilter.class); + httpSecurity.addFilterBefore(exceptionHandlerFilter(), JwtAuthenticationFilter.class); + + return httpSecurity.build(); + + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtTokenUtil, memberRepository); + } + + @Bean + public ExceptionHandlerFilter exceptionHandlerFilter() { + return new ExceptionHandlerFilter(objectMapper); + } + + @Bean + public CorsConfigurationSource corsConfiguration() { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + + corsConfiguration.addAllowedHeader("*"); + corsConfiguration.addExposedHeader("Authorization"); + corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + corsConfiguration.setAllowedOrigins(List.of( + "http://localhost:5173", + "http://localhost:8080", + "https://claco-client.vercel.app" + )); + corsConfiguration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + + source.registerCorsConfiguration("/**", corsConfiguration); + + return source; + } +} diff --git a/src/main/java/com/curateme/claco/global/config/SwaggerConfig.java b/src/main/java/com/curateme/claco/global/config/SwaggerConfig.java new file mode 100644 index 00000000..50f651f2 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/config/SwaggerConfig.java @@ -0,0 +1,43 @@ +package com.curateme.claco.global.config; + +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.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + + String jwtSchemeName = "JWT TOKEN"; + + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .addServersItem(new Server().url("/")) + .info(apiInfo()) + .addSecurityItem(securityRequirement) + .components(components); + } + + private Info apiInfo() { + return new Info() + .title("Claco Springdoc") + .description("Springdoc을 사용한 CLACO API 테스트") + .version("1.0.0"); + } +} + diff --git a/src/main/java/com/curateme/claco/global/controller/HealthCheckController.java b/src/main/java/com/curateme/claco/global/controller/HealthCheckController.java new file mode 100644 index 00000000..caa79ec7 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/controller/HealthCheckController.java @@ -0,0 +1,14 @@ +package com.curateme.claco.global.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthCheckController { + + @RequestMapping("/health-check") + public String healthCheck() { + return "curate me!"; + } + +} diff --git a/src/main/java/com/curateme/claco/global/entity/ActiveStatus.java b/src/main/java/com/curateme/claco/global/entity/ActiveStatus.java new file mode 100644 index 00000000..fce8b465 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/entity/ActiveStatus.java @@ -0,0 +1,27 @@ +package com.curateme.claco.global.entity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @packageName : com.curateme.claco.global.entity + * @fileName : ActiveStatus.java + * @author : 이 건 + * @date : 2024.10.15 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.15 이 건 최초 생성 + */ +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ActiveStatus { + + // ACTIVE: 활성, DELETED: 비활성 + ACTIVE("ACTIVE"), DELETED("DELETED"); + + private final String activeStatus; + +} diff --git a/src/main/java/com/curateme/claco/global/entity/BaseEntity.java b/src/main/java/com/curateme/claco/global/entity/BaseEntity.java new file mode 100644 index 00000000..9c3532fb --- /dev/null +++ b/src/main/java/com/curateme/claco/global/entity/BaseEntity.java @@ -0,0 +1,59 @@ +package com.curateme.claco.global.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import lombok.Getter; + +/** + * @packageName : com.curateme.claco.global.entity + * @fileName : BaseEntity.java + * @author : 이 건 + * @date : 2024.10.15 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.15 이 건 최초 생성 + */ +@Getter +@MappedSuperclass +public abstract class BaseEntity { + + // 활성 여부 + @Enumerated(value = EnumType.STRING) + private ActiveStatus activeStatus = ActiveStatus.ACTIVE; + // 생성일 + private LocalDateTime createdAt; + // 수정일 + private LocalDateTime updatedAt; + + @PrePersist + public void createdAt() { + LocalDateTime currentTime = LocalDateTime.now(); + this.createdAt = currentTime; + this.updatedAt = currentTime; + } + + @PreUpdate + public void updatedAt() { + this.updatedAt = LocalDateTime.now(); + } + + public void deleteEntity() { + this.activeStatus = ActiveStatus.DELETED; + } + + public void restoreEntity() { + this.activeStatus = ActiveStatus.ACTIVE; + } + + public void updateActiveStatus(ActiveStatus activeStatus) { + this.activeStatus = activeStatus; + } + +} diff --git a/src/main/java/com/curateme/claco/global/exception/BusinessException.java b/src/main/java/com/curateme/claco/global/exception/BusinessException.java new file mode 100644 index 00000000..f19e59e6 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/exception/BusinessException.java @@ -0,0 +1,27 @@ +package com.curateme.claco.global.exception; + +import com.curateme.claco.global.response.ApiStatus; + +import lombok.Getter; + +/** + * @packageName : com.curateme.claco.global.exception + * @fileName : BusinessException.java + * @author : 이 건 + * @date : 2024.10.14 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.14 이 건 최초 생성 + */ +@Getter +public class BusinessException extends RuntimeException{ + + private String code; + + public BusinessException(ApiStatus apiStatus) { + super(apiStatus.getMessage()); + this.code = apiStatus.getCode(); + } +} diff --git a/src/main/java/com/curateme/claco/global/exception/handler/CustomAsyncExceptionHandler.java b/src/main/java/com/curateme/claco/global/exception/handler/CustomAsyncExceptionHandler.java new file mode 100644 index 00000000..c7ac5db2 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/exception/handler/CustomAsyncExceptionHandler.java @@ -0,0 +1,14 @@ +package com.curateme.claco.global.exception.handler; + +import java.lang.reflect.Method; +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; + +@Slf4j +public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { + + @Override + public void handleUncaughtException(Throwable ex, Method method, Object... params) { + log.error("Async Error Message: {}", ex.getMessage()); + } +} diff --git a/src/main/java/com/curateme/claco/global/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/curateme/claco/global/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..b10c5c9d --- /dev/null +++ b/src/main/java/com/curateme/claco/global/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,56 @@ +package com.curateme.claco.global.exception.handler; + +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.global.response.ApiStatus; + +import lombok.extern.slf4j.Slf4j; + +/** + * @packageName : com.curateme.claco.global.exception.handler + * @fileName : GlobalExceptionHandler.java + * @author : 이 건 + * @date : 2024.10.14 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.14 이 건 최초 생성 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // TODO: 예외의 메시지 반환 부분 변경 (Exception) + @ExceptionHandler(value = Exception.class) + public ApiResponse exceptionHandler(Exception exception) { + + log.error("[Exception] -> message: {}", exception.getMessage()); + log.debug("[Exception] ->", exception); + + return ApiResponse.fail(ApiStatus.EXCEPTION_OCCUR); + } + + // TODO: 예외의 메시지 반환 부분 변경 (RuntimeException) + @ExceptionHandler(value = RuntimeException.class) + public ApiResponse runtimeExceptionHandler(RuntimeException runtimeException) { + + log.error("[RuntimeException] -> message: {}", runtimeException.getMessage()); + log.debug("[RuntimeException] ->", runtimeException); + + return ApiResponse.fail(ApiStatus.RUNTIME_EXCEPTION_OCCUR); + } + + @ExceptionHandler(value = BusinessException.class) + public ApiResponse businessExceptionHandler(BusinessException businessException) { + + log.error("[BusinessException] -> message: {}", businessException.getMessage()); + log.debug("[BusinessException] ->", businessException); + + return ApiResponse.fail(businessException.getCode(), businessException.getMessage()); + } + +} diff --git a/src/main/java/com/curateme/claco/global/filter/ExceptionHandlerFilter.java b/src/main/java/com/curateme/claco/global/filter/ExceptionHandlerFilter.java new file mode 100644 index 00000000..8cf9a202 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/filter/ExceptionHandlerFilter.java @@ -0,0 +1,56 @@ +package com.curateme.claco.global.filter; + +import java.io.IOException; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.global.response.ApiStatus; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class ExceptionHandlerFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + filterChain.doFilter(request, response); + } catch (BusinessException exception) { + + log.error("[FilterBusinessException {}] = {}", exception.getCode(), exception.getMessage()); + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + response.getWriter() + .write(objectMapper.writeValueAsString(ApiResponse.fail(exception.getCode(), exception.getMessage()))); + + } catch (Exception exception) { + + log.error("[FilterException] = {}", exception.getMessage()); + + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.fail(ApiStatus.EXCEPTION_OCCUR))); + + } + + } +} diff --git a/src/main/java/com/curateme/claco/global/response/ApiResponse.java b/src/main/java/com/curateme/claco/global/response/ApiResponse.java new file mode 100644 index 00000000..27b3ed25 --- /dev/null +++ b/src/main/java/com/curateme/claco/global/response/ApiResponse.java @@ -0,0 +1,42 @@ +package com.curateme.claco.global.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ApiResponse { + + @Schema(description = "응답 코드", example = "COM-000") + private final String code; + @Schema(description = "응답 메시지", example = "OK.") + private final String message; + @JsonInclude(JsonInclude.Include.NON_NULL) + private T result; + @Schema(description = "토큰 재발급 여부 확인, true: 재발급, false: 재발급 안됨") + @Setter + private Boolean refreshed; + + public static ApiResponse ok() { + return new ApiResponse<>(ApiStatus.OK.getCode(), ApiStatus.OK.getMessage(), null, false); + } + + // 성공한 경우 응답 생성 + public static ApiResponse ok(T result) { + return new ApiResponse<>(ApiStatus.OK.getCode(), ApiStatus.OK.getMessage(), result, false); + } + + // 실패한 경우 응답 생성 + public static ApiResponse fail(String code, String message) { + return new ApiResponse<>(code, message, null, false); + } + + public static ApiResponse fail(ApiStatus apiStatus) { + return new ApiResponse<>(apiStatus.getCode(), apiStatus.getMessage(), null, false); + } +} \ No newline at end of file diff --git a/src/main/java/com/curateme/claco/global/response/ApiStatus.java b/src/main/java/com/curateme/claco/global/response/ApiStatus.java new file mode 100644 index 00000000..682425ee --- /dev/null +++ b/src/main/java/com/curateme/claco/global/response/ApiStatus.java @@ -0,0 +1,71 @@ +package com.curateme.claco.global.response; + +import org.springframework.http.HttpStatus; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @packageName : com.curateme.claco.global.response + * @fileName : ApiStatus.java + * @author : 이 건 + * @date : 2024.10.14 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.14 이 건 최초 생성 + * 2024.10.15 이 건 로그인 및 멤버 관련 에러 추가 + */ +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ApiStatus { + + // 성공 응답 + OK(HttpStatus.OK, "COM-000", "Ok"), + + // 서버 에러 + EXCEPTION_OCCUR(HttpStatus.INTERNAL_SERVER_ERROR, "DBG-500", "Something went wrong."), + RUNTIME_EXCEPTION_OCCUR(HttpStatus.INTERNAL_SERVER_ERROR, "DBG-501", "Something went wrong."), + EXCEED_SIZE_LIMIT(HttpStatus.BAD_REQUEST, "SZE-001", "Request Size is too big."), + + // 멤버 에러 + MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEM-001", "Member not found."), + MEMBER_NICKNAME_DUPLICATE(HttpStatus.CONFLICT, "MEM-009", "Nickname is duplicated. Try again."), + MEMBER_NOT_OWNER(HttpStatus.UNAUTHORIZED, "MEM-999", "Member is not an owner of resource."), + + // 로그인 에러 + ACCESS_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "ACT-001", "AccessToken not found."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "RFT-001", "RefreshToken not found."), + MEMBER_LOGIN_SESSION_EXPIRED(HttpStatus.BAD_REQUEST, "MSE-001", "Member login session expired."), + OAUTH_ATTRIBUTE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "ATH-001", "Cannot find OAuth attribute."), + + // 클라코 북 에러 + CLACO_BOOK_NOT_FOUND(HttpStatus.BAD_REQUEST, "CLB-001", "Claco book not found."), + CLACO_BOOK_CREATION_LIMIT(HttpStatus.BAD_REQUEST, "CLB-010", "Claco Book can create maximum 5."), + + // 티켓 리뷰 에러 + IMAGE_TOO_MANY(HttpStatus.BAD_REQUEST, "TCK-010", "Review image is too many.(limit 3)"), + TICKET_REVIEW_NOT_FOUND(HttpStatus.BAD_REQUEST, "TCK-001", "Ticket Review not found."), + + // 이미지 에러 + IMAGE_NOT_FOUND(HttpStatus.BAD_REQUEST, "IMG-001", "Image not found."), + S3_UPLOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "IMG-010", "Image upload fail."), + + // 공연 에러 + CONCERT_NOT_FOUND(HttpStatus.BAD_REQUEST, "CON-001", "Concert not found."), + + // 장소평 에러 + PLACE_CATEGORY_NOT_FOUND(HttpStatus.BAD_REQUEST, "PLC-001", "PlaceCategory not found."), + + // 공연 감상 에러 + CONCERT_TAG_NOT_FOUND(HttpStatus.BAD_REQUEST, "CTG-001", "ConcertTage not found.") + + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/curateme/claco/global/response/PageResponse.java b/src/main/java/com/curateme/claco/global/response/PageResponse.java new file mode 100644 index 00000000..e9a350ae --- /dev/null +++ b/src/main/java/com/curateme/claco/global/response/PageResponse.java @@ -0,0 +1,35 @@ +package com.curateme.claco.global.response; + +import jakarta.persistence.criteria.CriteriaBuilder.In; +import java.util.ArrayList; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PageResponse { + + private List listPageResponse = new ArrayList<>(); + + private Long totalCount; + + private Integer size; + + private Integer currentPage; + + private Integer totalPage; + + @Builder + public PageResponse(List listPageResponse, Long totalCount, Integer size, Integer currentPage, Integer totalPage) { + + this.listPageResponse = listPageResponse; + this.totalCount = totalCount; + this.size = size; + this.currentPage = currentPage; + this.totalPage = totalPage; + + } + +} diff --git a/src/main/java/com/curateme/claco/global/util/S3Util.java b/src/main/java/com/curateme/claco/global/util/S3Util.java new file mode 100644 index 00000000..6f82eacb --- /dev/null +++ b/src/main/java/com/curateme/claco/global/util/S3Util.java @@ -0,0 +1,40 @@ +package com.curateme.claco.global.util; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class S3Util { + + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket-name}") + private String bucket; + + public String uploadImage(MultipartFile multipartFile, String filePath) throws IOException { + + if (multipartFile == null) { + throw new BusinessException(ApiStatus.IMAGE_NOT_FOUND); + } + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(multipartFile.getSize()); + metadata.setContentType(multipartFile.getContentType()); + + amazonS3.putObject(bucket, filePath, multipartFile.getInputStream(), metadata); + + return amazonS3.getUrl(bucket, filePath).toString(); + } + +} diff --git a/src/main/java/com/curateme/claco/member/controller/MemberController.java b/src/main/java/com/curateme/claco/member/controller/MemberController.java new file mode 100644 index 00000000..efd83c07 --- /dev/null +++ b/src/main/java/com/curateme/claco/member/controller/MemberController.java @@ -0,0 +1,120 @@ +package com.curateme.claco.member.controller; + +import java.io.IOException; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.curateme.claco.global.annotation.TokenRefreshedCheck; +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.member.domain.dto.request.SignUpRequest; +import com.curateme.claco.member.domain.dto.response.MemberInfoResponse; +import com.curateme.claco.member.service.MemberService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + * 2024.11.05 이 건 회원 정보 조회 및 수정 추가, Swagger 적용 + */ +@TokenRefreshedCheck +@RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + /** + * GET /api/members/nickname + * 닉네임 유효성 체크 + * @param nickname : 체크하고자 하는 닉네임 + * @return : COM-000 정상, MEM-009 닉네임 중복 + */ + @Operation(summary = "닉네임 중복 조회", description = "닉네임 중복 조회") + @Parameter(name = "nickname", description = "체크하고자 하는 닉네임(필수)") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-009", description = "중복된 닉네임") + }) + @GetMapping("/check-nickname") + public ApiResponse checkNicknameDuplicate(@RequestParam("nickname") String nickname) { + memberService.checkNicknameValid(nickname); + + return ApiResponse.ok(); + } + + /** + * GET /api/members + * 유저 정보 조회 + * @return : 유저 정보 (닉네임, 프사) + */ + @Operation(summary = "유저 정보 조회", description = "유저 정보 조회, 토큰 포함 요청 필요") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "조회하고자 하는 사용자를 찾을 수 없음(잘못된 토큰 정보)") + }) + @GetMapping + public ApiResponse readMemberInfo() { + return ApiResponse.ok(memberService.readMemberInfo()); + } + + /** + * POST /api/members/sign-up + * 회원가입 정보 입력 + * @param request : 회원가입하고자 하는 정보 (닉네임, 선호 정보) + * @return : COM-000 정상, MEM-009 닉네임 중복 + */ + @Operation(summary = "회원 가입 메서드(카카오 이후 취향 입력)", description = "카카오 로그인 이후 회원 가입 메서드, 닉네임 중복 검사 완료 되어야함") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "가입하고자 하는 사용자를 찾을 수 없음(잘못된 토큰 정보)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-009", description = "중복된 닉네임") + }) + @PostMapping + public ApiResponse signUp(@RequestBody SignUpRequest request) { + memberService.signUp(request); + + return ApiResponse.ok(); + } + + /** + * + * @param updateNickname : 업데이트 하고자 하는 닉네임, 그대로면 null + * @param updateImage : 업데이트 하고자 하는 프사, 그대로면 null + * @return + * @throws IOException + */ + @Operation(summary = "유저 정보 수정", description = "유저 정보 수정 api(닉네임, 이미지)") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "수정하고자 하는 사용자를 찾을 수 없음(잘못된 토큰 정보)") + }) + @Parameter(name = "updateNickname", description = "업데이트 하고자 하는 닉네임, 그대로여도 key-value에서 key는 보내고 value는 null로", required = true) + @Parameter(name = "updateImage", description = "업데이트 하고자 하는 새로운 이미지(멀티 파트 파일), 그대로여도 key-value에서 key는 보내고 value는 null로", required = true) + @PutMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse updateMemberInfo( + @RequestPart(value = "updateNickname", required = false) String updateNickname, + @RequestPart(value = "updateImage", required = false)MultipartFile updateImage + ) throws IOException { + return ApiResponse.ok(memberService.updateMemberInfo(updateNickname, updateImage)); + } + +} diff --git a/src/main/java/com/curateme/claco/member/domain/dto/request/SignUpRequest.java b/src/main/java/com/curateme/claco/member/domain/dto/request/SignUpRequest.java new file mode 100644 index 00000000..70ed2ffc --- /dev/null +++ b/src/main/java/com/curateme/claco/member/domain/dto/request/SignUpRequest.java @@ -0,0 +1,46 @@ +package com.curateme.claco.member.domain.dto.request; + +import java.util.List; + +import com.curateme.claco.member.domain.entity.Gender; +import com.curateme.claco.preference.domain.vo.CategoryPreferenceVO; +import com.curateme.claco.preference.domain.vo.RegionPreferenceVO; +import com.curateme.claco.preference.domain.vo.TypePreferenceVO; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SignUpRequest { + + @NotNull + @Schema(description = "닉네임", example = "클라코 사용자") + private String nickname; + @NotNull + @Schema(description = "성별, MALE 아니면 FEMALE로 입력", example = "FEMALE") + private Gender gender; + @NotNull + @Schema(description = "나이", example = "15") + private Integer age; + @NotNull + @Schema(description = "선호 최소 가격", example = "1000") + private Integer minPrice; + @NotNull + @Schema(description = "선호 최대 가격", example = "1000000") + private Integer maxPrice; + @Schema(description = "지역 선호도 선택한 문자열 리스트") + private List regionPreferences; + @Schema(description = "공연 유형 선택한 문자열 리스트") + private List typePreferences; + @Schema(description = "공연 성격 선택한 문자열 리스트") + private List categoryPreferences; + +} diff --git a/src/main/java/com/curateme/claco/member/domain/dto/response/MemberInfoResponse.java b/src/main/java/com/curateme/claco/member/domain/dto/response/MemberInfoResponse.java new file mode 100644 index 00000000..cbaef10d --- /dev/null +++ b/src/main/java/com/curateme/claco/member/domain/dto/response/MemberInfoResponse.java @@ -0,0 +1,38 @@ +package com.curateme.claco.member.domain.dto.response; + +import com.curateme.claco.member.domain.entity.Member; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.05 + * @author devkeon(devkeon123 @ gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.05 이 건 최초 생성 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberInfoResponse { + + // 닉네임 + @Schema(description = "회원 설정 닉네임", example = "클라코사용자") + private String nickname; + // 이미지 Url + @Schema(description = "회원 프사 url", example = "https://claco-defaul/default.png") + private String imageUrl; + + public static MemberInfoResponse fromEntity(Member member) { + return new MemberInfoResponse(member.getNickname(), member.getProfileImage()); + } + +} diff --git a/src/main/java/com/curateme/claco/member/domain/entity/Gender.java b/src/main/java/com/curateme/claco/member/domain/entity/Gender.java new file mode 100644 index 00000000..9321fc41 --- /dev/null +++ b/src/main/java/com/curateme/claco/member/domain/entity/Gender.java @@ -0,0 +1,23 @@ +package com.curateme.claco.member.domain.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + */ +@Getter +@AllArgsConstructor +public enum Gender { + + MALE("MALE"), FEMALE("FEMALE"); + + private final String gender; + +} diff --git a/src/main/java/com/curateme/claco/member/domain/entity/Member.java b/src/main/java/com/curateme/claco/member/domain/entity/Member.java new file mode 100644 index 00000000..b2006dfe --- /dev/null +++ b/src/main/java/com/curateme/claco/member/domain/entity/Member.java @@ -0,0 +1,163 @@ +package com.curateme.claco.member.domain.entity; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.global.entity.BaseEntity; +import com.curateme.claco.preference.domain.entity.Preference; +import com.curateme.claco.review.domain.entity.TicketReview; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @packageName : com.curateme.claco.member.entity + * @fileName : Member.java + * @author : 이 건 + * @date : 2024.10.15 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.15 이 건 최초 생성 + * 2024.10.16 이 건 빌더 추가 및 MemberType 명칭 변경 + * 2024.10.17 이 건 엔티티 필드 제약 조건 변경 + * 2024.10.18 이 건 성별 필드 추가 (Gender) 및 Preference 관계 매핑 + * 2024.10.22 이 건 나이 필드 추가 및 Preference 매핑 condition 수정 + * 2024.10.24 이 건 ClacoBook 일대다 엔티티 매핑, soft delete 조건 추가 + * 2024.10.28 이 건 TicketReview 일대다 엔티티 매핑 추가 + * 2024.11.05 이 건 닉네임, 프로필 이미지 업데이트 메서드 추가 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE member SET active_status = 'DELETED' WHERE member_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class Member extends BaseEntity { + + // auto_increment 사용 id + @Id @Column(name = "member_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // Preference 일대일 양방향 매핑 (주 테이블) + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "preference_id") + private Preference preference; + + // ClacoBook 일대다 양방향 매핑 + @Builder.Default + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List clacoBooks = new ArrayList<>(); + + // TicketReview 일대다 양방향 매핑 + @Builder.Default + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List ticketReviews = new ArrayList<>(); + + // email + @NotNull + @Email + private String email; + // 닉네임 (15글자 제약) + @Column(unique = true, length = 15) + private String nickname; + // 소셜 id (카카오) + @NotNull + @Column(unique = true) + private Long socialId; + // 가입 타입 + @NotNull + @Enumerated(value = EnumType.STRING) + private Role role; + // 성별 + @Enumerated(value = EnumType.STRING) + private Gender gender; + // 나이대 (10, 20, 30, 40, 50, 60) + @Column(length = 2) + private Integer age; + // 프로필 이미지 url + private String profileImage; + // refresh token + private String refreshToken; + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void updateNickname(String nickname) { + if (nickname != null) { + this.nickname = nickname; + } + } + + public void updateProfileImage(String profileImage) { + if (profileImage != null) { + this.profileImage = profileImage; + } + } + + public void updateGender(Gender gender) { + if (gender != null) { + this.gender = gender; + } + } + + public void updatePreference(Preference preference) { + this.preference = preference; + } + + public void updateAge(Integer age) { + if (age != null) { + this.age = age; + } + } + + public void updateRole() { + this.role = Role.MEMBER; + } + + // 연관관계 편의 메서드 + public void addClacoBook(ClacoBook clacoBook) { + if (!this.clacoBooks.contains(clacoBook)) { + this.clacoBooks.add(clacoBook); + } + if (clacoBook.getMember() != this) { + clacoBook.updateMember(this); + } + } + + public void addTicketReview(TicketReview ticketReview) { + if (!this.ticketReviews.contains(ticketReview)) { + this.ticketReviews.add(ticketReview); + } + if (ticketReview.getMember() != this) { + ticketReview.updateMember(this); + } + } + + +} diff --git a/src/main/java/com/curateme/claco/member/domain/entity/Role.java b/src/main/java/com/curateme/claco/member/domain/entity/Role.java new file mode 100644 index 00000000..4597575f --- /dev/null +++ b/src/main/java/com/curateme/claco/member/domain/entity/Role.java @@ -0,0 +1,29 @@ +package com.curateme.claco.member.domain.entity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @packageName : com.curateme.claco.member.entity + * @fileName : MemberType.java + * @author : 이 건 + * @date : 2024.10.15 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.15 이 건 최초 생성 + * 2024.10.16 이 건 명칭 변경 (MemberType -> MemberRole) + * 2024.10.17 이 건 명칭 변경 (MemberRoke -> Role) + */ +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum Role { + + // 소셜 (회원 가입 중), 회원 (회원 가입 완료), 관리자 + SOCIAL("SOCIAL"), MEMBER("MEMBER"), ADMIN("ADMIN"); + + private final String role; + +} diff --git a/src/main/java/com/curateme/claco/member/repository/MemberRepository.java b/src/main/java/com/curateme/claco/member/repository/MemberRepository.java new file mode 100644 index 00000000..1ab0869f --- /dev/null +++ b/src/main/java/com/curateme/claco/member/repository/MemberRepository.java @@ -0,0 +1,58 @@ +package com.curateme.claco.member.repository; + +import java.util.Optional; + +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.curateme.claco.member.domain.entity.Member; + +/** + * @packageName : com.curateme.claco.member.repository + * @fileName : MemberRepository.java + * @author : 이 건 + * @date : 2024.10.17 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.17 이 건 최초 생성 + * 2024.10.18 이 건 nickname 메서드 추가 -> id 로 변경 + * 2024.11.05 이 건 preference entity graph 메서드 추가 + */ +public interface MemberRepository extends JpaRepository { + + /** + * 소셜 id 로 Member 찾는 메서드 + * @param socialId : OAuth 가입 시 발급된 socialId + * @return : Optional Member + */ + Optional findMemberBySocialId(Long socialId); + + /** + * 닉네임으로 Member 찾는 메서드 + * @param nickname : 찾고자 하는 닉네임 + * @return : Optional Member + */ + Optional findMemberByNickname(String nickname); + + /** + * 닉네임으로 Member 를 클라코 북과 함께 찾는 메서드 + * @param id : 찾고자 하는 유저의 id + * @return : Optional Member + */ + @EntityGraph(attributePaths = {"clacoBooks"}) + @Query("select m from Member m where m.id=:id") + Optional findMemberByIdWithClacoBook(@Param("id") Long id); + + /** + * preference, typePreferences, regionPreferences 조인하여 조회 + * @param id : 찾고자 하는 회원의 id + * @return : 취향 정보를 모두 포함한 회원 정보 + */ + @EntityGraph(attributePaths = {"preference", "preference.typePreferences", "preference.regionPreferences"}) + Optional findMemberByIdIs(Long id); + +} diff --git a/src/main/java/com/curateme/claco/member/service/MemberService.java b/src/main/java/com/curateme/claco/member/service/MemberService.java new file mode 100644 index 00000000..3936000b --- /dev/null +++ b/src/main/java/com/curateme/claco/member/service/MemberService.java @@ -0,0 +1,49 @@ +package com.curateme.claco.member.service; + +import java.io.IOException; + +import org.springframework.web.multipart.MultipartFile; + +import com.curateme.claco.member.domain.dto.request.SignUpRequest; +import com.curateme.claco.member.domain.dto.response.MemberInfoResponse; + +/** + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + * 2024.10.22 이 건 메서드 반환 타입 void로 변경(예외 활용에 따라) + * 2024.11.05 이 건 회원 정보 조회 및 수정 메서드 추가 + */ +public interface MemberService { + + /** + * 닉네임 유효성 체크 (중복 검사) + * @param nickname : 검사하고자 하는 닉네임 + */ + void checkNicknameValid(String nickname); + + /** + * 취향 정보, 닉네임 정보를 받아와 회원가입을 완료 + * @param signUpRequest: nickname, gender, price, preference 정보 + */ + void signUp(SignUpRequest signUpRequest); + + /** + * 회원 정보 불러오기 (닉네임, 이미지 url) + * @return 닉네임, 이미지 url + */ + MemberInfoResponse readMemberInfo(); + + /** + * 회원 정보 수정 (닉네임, 이미지) + * @param updateNickname : 수정하고자 하는 닉네임 + * @param updateImage : 이미지 파일 + * @return : 수정 후 닉네임, 이미지 url + */ + MemberInfoResponse updateMemberInfo(String updateNickname, MultipartFile updateImage) throws IOException; + +} diff --git a/src/main/java/com/curateme/claco/member/service/MemberServiceV1.java b/src/main/java/com/curateme/claco/member/service/MemberServiceV1.java new file mode 100644 index 00000000..06b6923d --- /dev/null +++ b/src/main/java/com/curateme/claco/member/service/MemberServiceV1.java @@ -0,0 +1,99 @@ +package com.curateme.claco.member.service; + +import java.io.IOException; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.global.util.S3Util; +import com.curateme.claco.member.domain.dto.request.SignUpRequest; +import com.curateme.claco.member.domain.dto.response.MemberInfoResponse; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; +import com.curateme.claco.preference.domain.entity.Preference; +import com.curateme.claco.preference.service.PreferenceService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + * 2024.10.22 이 건 예외를 활용한 로직으로 변경, 회원가입 메서드 추가 + * 2024.11.05 이 건 회원 정보 조회 / 수정 메서드 추가 + */ +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class MemberServiceV1 implements MemberService { + + private final MemberRepository memberRepository; + private final PreferenceService preferenceService; + private final SecurityContextUtil securityContextUtil; + private final S3Util s3Util; + + @Override + public void checkNicknameValid(String nickname) { + memberRepository.findMemberByNickname(nickname).stream() + .findAny() + .ifPresent(member -> { + throw new BusinessException(ApiStatus.MEMBER_NICKNAME_DUPLICATE); + }); + } + + @Override + public void signUp(SignUpRequest signUpRequest) { + + checkNicknameValid(signUpRequest.getNickname()); + + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + member.updateNickname(signUpRequest.getNickname()); + member.updateGender(signUpRequest.getGender()); + member.updateAge(signUpRequest.getAge()); + member.updateRole(); + + Preference preference = preferenceService.savePreference(signUpRequest); + member.updatePreference(preference); + + } + + @Override + public MemberInfoResponse readMemberInfo() { + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + return MemberInfoResponse.fromEntity(member); + } + + @Override + public MemberInfoResponse updateMemberInfo(String updateNickname, MultipartFile updateImage) throws IOException { + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + member.updateNickname(updateNickname); + + String profileImageLocation = "member/profile-image/" + member.getId(); + + if (updateImage != null) { + String url = s3Util.uploadImage(updateImage, profileImageLocation); + member.updateProfileImage(url); + } + + return MemberInfoResponse.fromEntity(member); + } +} diff --git a/src/main/java/com/curateme/claco/preference/controller/PreferenceController.java b/src/main/java/com/curateme/claco/preference/controller/PreferenceController.java new file mode 100644 index 00000000..45789197 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/controller/PreferenceController.java @@ -0,0 +1,56 @@ +package com.curateme.claco.preference.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.curateme.claco.global.annotation.TokenRefreshedCheck; +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.preference.domain.dto.request.PreferenceUpdateRequest; +import com.curateme.claco.preference.domain.dto.response.PreferenceInfoResponse; +import com.curateme.claco.preference.service.PreferenceService; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.05 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.05 이 건 최초 생성 + */ +@TokenRefreshedCheck +@RestController +@RequestMapping("/api/preferences") +@RequiredArgsConstructor +public class PreferenceController { + + private final PreferenceService preferenceService; + + /** + * 접근 유저의 취향 데이터 조회 + * @return : 취향 데이터 + */ + @Operation(summary = "취향 조회", description = "접근 유저 취향 조회 기능(나이, 성별, 선호 지역, 선호 공연 취향, 선호 공연 유형, 가격)") + @GetMapping + public ApiResponse readPreference() { + return ApiResponse.ok(preferenceService.readPreference()); + } + + /** + * 접근 유저의 취향 데이터 수정 + * @param request : 취향 데이터 수정 정보 (성별, 나이, 가격, 지역, 타입) + * @return : 취향 데이터 (수정 후) + */ + @Operation(summary = "취향 수정", description = "접근 유저 취향 수정 기능") + @PutMapping + public ApiResponse updatePreference(@RequestBody PreferenceUpdateRequest request) { + return ApiResponse.ok(preferenceService.updatePreference(request)); + } + +} diff --git a/src/main/java/com/curateme/claco/preference/domain/dto/request/PreferenceUpdateRequest.java b/src/main/java/com/curateme/claco/preference/domain/dto/request/PreferenceUpdateRequest.java new file mode 100644 index 00000000..7e54c559 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/domain/dto/request/PreferenceUpdateRequest.java @@ -0,0 +1,44 @@ +package com.curateme.claco.preference.domain.dto.request; + +import java.util.List; + +import com.curateme.claco.member.domain.entity.Gender; +import com.curateme.claco.preference.domain.vo.RegionPreferenceVO; +import com.curateme.claco.preference.domain.vo.TypePreferenceVO; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.05 + * @author devkeon(devkeon123 @ gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.05 이 건 최초 생성 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PreferenceUpdateRequest { + + @Schema(description = "수정하고자 하는 성별, 수정 없으면 null", example = "MALE / FEMALE") + private Gender gender; + @Schema(description = "수정하고자 하는 나이, 수정 없으면 null", example = "26") + private Integer age; + @Schema(description = "수정하고자 하는 최소 가격, 수정 없으면 null", example = "1000") + private Integer minPrice; + @Schema(description = "수정하고자 하는 최대 가격, 수정 없으면 null", example = "1000000") + private Integer maxPrice; + @Schema(description = "수정하고자 하는 지역 선호도, 새롭게 추가된 데이터 + 유지된 데이터") + private List regionPreferences; + @Schema(description = "수정하고자 하는 공연 유형, 새롭게 추가된 데이터 + 유지된 데이터") + private List typePreferences; + +} diff --git a/src/main/java/com/curateme/claco/preference/domain/dto/response/PreferenceInfoResponse.java b/src/main/java/com/curateme/claco/preference/domain/dto/response/PreferenceInfoResponse.java new file mode 100644 index 00000000..49267731 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/domain/dto/response/PreferenceInfoResponse.java @@ -0,0 +1,39 @@ +package com.curateme.claco.preference.domain.dto.response; + +import java.util.List; + +import com.curateme.claco.member.domain.entity.Gender; +import com.curateme.claco.preference.domain.vo.CategoryPreferenceVO; +import com.curateme.claco.preference.domain.vo.RegionPreferenceVO; +import com.curateme.claco.preference.domain.vo.TypePreferenceVO; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.05 + * @author devkeon(devkeon123 @ gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.05 이 건 최초 생성 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PreferenceInfoResponse { + + private Gender gender; + private Integer age; + private Integer minPrice; + private Integer maxPrice; + private List preferRegions; + private List preferCategories; + private List preferTypes; + +} diff --git a/src/main/java/com/curateme/claco/preference/domain/entity/Preference.java b/src/main/java/com/curateme/claco/preference/domain/entity/Preference.java new file mode 100644 index 00000000..2e6d3636 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/domain/entity/Preference.java @@ -0,0 +1,107 @@ +package com.curateme.claco.preference.domain.entity; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.global.entity.BaseEntity; +import com.curateme.claco.member.domain.entity.Member; + +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.OneToMany; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + * 2024.10.24 이 건 soft delete 조건 추가 + * 2024.11.05 이 건 카테시안 곱에 대비한 List -> Set으로 변경 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE preference SET active_status = 'DELETED' WHERE preference_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class Preference extends BaseEntity { + + // auto_increment 사용 id + @Id + @Column(name = "preference_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // Member 일대일 양방향 매핑 (대상 테이블) + @OneToOne(mappedBy = "preference", fetch = FetchType.LAZY) + private Member member; + // 다대일 매핑 + @Builder.Default + @OneToMany(mappedBy = "preference", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private Set typePreferences= new HashSet<>(); + // 다대일 매핑 + @Builder.Default + @OneToMany(mappedBy = "preference", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private Set regionPreferences = new HashSet<>(); + + // 카테고리 기반 취향 + private String preference1; + private String preference2; + private String preference3; + private String preference4; + private String preference5; + // 선호 가격대 + private Integer minPrice; + private Integer maxPrice; + + public void updateMinPrice(Integer minPrice) { + if (minPrice != null) { + this.minPrice = minPrice; + } + } + + public void updateMaxPrice(Integer maxPrice) { + if (maxPrice != null) { + this.maxPrice = maxPrice; + } + } + + // 연관관계 편의 메서드 + public void addTypeReference(TypePreference typePreference) { + if (!this.typePreferences.contains(typePreference)) { + this.typePreferences.add(typePreference); + } + if (typePreference.getPreference() != this) { + typePreference.updatePreference(this); + } + } + // 연관관계 편의 메서드 + public void addRegionPreference(RegionPreference regionPreference) { + if (!this.regionPreferences.contains(regionPreference)) { + this.regionPreferences.add(regionPreference); + } + if (regionPreference.getPreference() != this) { + regionPreference.updatePreference(this); + } + } +} diff --git a/src/main/java/com/curateme/claco/preference/domain/entity/RegionPreference.java b/src/main/java/com/curateme/claco/preference/domain/entity/RegionPreference.java new file mode 100644 index 00000000..ee1a5303 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/domain/entity/RegionPreference.java @@ -0,0 +1,67 @@ +package com.curateme.claco.preference.domain.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.global.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + * 2024.10.24 이 건 soft delete 조건 추가 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE region_preference SET active_status = 'DELETED' WHERE region_preference_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class RegionPreference extends BaseEntity { + + @Id @Column(name = "region_preference_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + // 지역 이름 + private String regionName; + @ManyToOne + @JoinColumn(name = "preference_id") + private Preference preference; + + // 정적 팩토리 메서드 + public static RegionPreference of(String regionName) { + RegionPreference regionPreference = new RegionPreference(); + regionPreference.regionName = regionName; + + return regionPreference; + } + + // 연관관계 편의 메서드 + public void updatePreference(Preference preference) { + if (this.preference != preference) { + this.preference = preference; + } + if (!preference.getRegionPreferences().contains(this)) { + preference.addRegionPreference(this); + } + } + +} diff --git a/src/main/java/com/curateme/claco/preference/domain/entity/TypePreference.java b/src/main/java/com/curateme/claco/preference/domain/entity/TypePreference.java new file mode 100644 index 00000000..3c5ad9f6 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/domain/entity/TypePreference.java @@ -0,0 +1,68 @@ +package com.curateme.claco.preference.domain.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.global.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + * 2024.10.24 이 건 soft delete 조건 추가 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE type_preference SET active_status = 'DELETED' WHERE type_preference_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class TypePreference extends BaseEntity { + + @Id + @Column(name = "type_preference_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + // 선택한 타입 내용 + private String typeContent; + // 다대일 매핑 + @ManyToOne + @JoinColumn(name = "preference_id") + private Preference preference; + // 정적 팩토리 메서드 + public static TypePreference of(String typeContent) { + TypePreference typePreference = new TypePreference(); + typePreference.typeContent = typeContent; + + return typePreference; + } + + // 연관관계 편의 메서드 + public void updatePreference(Preference preference) { + if (this.preference != preference) { + this.preference = preference; + } + if (!preference.getTypePreferences().contains(this)) { + preference.addTypeReference(this); + } + } + +} diff --git a/src/main/java/com/curateme/claco/preference/domain/vo/CategoryPreferenceVO.java b/src/main/java/com/curateme/claco/preference/domain/vo/CategoryPreferenceVO.java new file mode 100644 index 00000000..82e29fe7 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/domain/vo/CategoryPreferenceVO.java @@ -0,0 +1,17 @@ +package com.curateme.claco.preference.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CategoryPreferenceVO { + + @Schema(description = "선택한 공연 성격 정보", example = "웅장한") + private String preferenceCategory; + +} diff --git a/src/main/java/com/curateme/claco/preference/domain/vo/RegionPreferenceVO.java b/src/main/java/com/curateme/claco/preference/domain/vo/RegionPreferenceVO.java new file mode 100644 index 00000000..c80539ee --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/domain/vo/RegionPreferenceVO.java @@ -0,0 +1,23 @@ +package com.curateme.claco.preference.domain.vo; + +import com.curateme.claco.preference.domain.entity.RegionPreference; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class RegionPreferenceVO { + + @Schema(description = "수정하고자 하는 지역 이름", example = "서울") + private String preferenceRegion; + + public static RegionPreferenceVO fromEntity(RegionPreference regionPreference) { + return new RegionPreferenceVO(regionPreference.getRegionName()); + } + +} diff --git a/src/main/java/com/curateme/claco/preference/domain/vo/TypePreferenceVO.java b/src/main/java/com/curateme/claco/preference/domain/vo/TypePreferenceVO.java new file mode 100644 index 00000000..3a70c42a --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/domain/vo/TypePreferenceVO.java @@ -0,0 +1,23 @@ +package com.curateme.claco.preference.domain.vo; + +import com.curateme.claco.preference.domain.entity.TypePreference; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TypePreferenceVO { + + @Schema(description = "수정하고자 하는 유형 내용", example = "깊이 있는 클래식 공연이 좋아요.") + private String preferenceType; + + public static TypePreferenceVO fromEntity(TypePreference typePreference) { + return new TypePreferenceVO(typePreference.getTypeContent()); + } + +} diff --git a/src/main/java/com/curateme/claco/preference/repository/PreferenceRepository.java b/src/main/java/com/curateme/claco/preference/repository/PreferenceRepository.java new file mode 100644 index 00000000..9b9055fa --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/repository/PreferenceRepository.java @@ -0,0 +1,17 @@ +package com.curateme.claco.preference.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.curateme.claco.preference.domain.entity.Preference; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + */ +public interface PreferenceRepository extends JpaRepository { +} diff --git a/src/main/java/com/curateme/claco/preference/repository/RegionPreferenceRepository.java b/src/main/java/com/curateme/claco/preference/repository/RegionPreferenceRepository.java new file mode 100644 index 00000000..d2be04a9 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/repository/RegionPreferenceRepository.java @@ -0,0 +1,17 @@ +package com.curateme.claco.preference.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.curateme.claco.preference.domain.entity.RegionPreference; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + */ +public interface RegionPreferenceRepository extends JpaRepository { +} diff --git a/src/main/java/com/curateme/claco/preference/repository/TypePreferenceRepository.java b/src/main/java/com/curateme/claco/preference/repository/TypePreferenceRepository.java new file mode 100644 index 00000000..da9afd95 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/repository/TypePreferenceRepository.java @@ -0,0 +1,17 @@ +package com.curateme.claco.preference.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.curateme.claco.preference.domain.entity.TypePreference; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + */ +public interface TypePreferenceRepository extends JpaRepository { +} diff --git a/src/main/java/com/curateme/claco/preference/service/PreferenceService.java b/src/main/java/com/curateme/claco/preference/service/PreferenceService.java new file mode 100644 index 00000000..d888a238 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/service/PreferenceService.java @@ -0,0 +1,40 @@ +package com.curateme.claco.preference.service; + +import com.curateme.claco.member.domain.dto.request.SignUpRequest; +import com.curateme.claco.preference.domain.dto.request.PreferenceUpdateRequest; +import com.curateme.claco.preference.domain.dto.response.PreferenceInfoResponse; +import com.curateme.claco.preference.domain.entity.Preference; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + * 2024.11.05 이 건 취향 정보 조회, 수정 메서드 추가 + */ +public interface PreferenceService { + + /** + * 관련 Preference 객체를 생성하고 저장하는 메서드 + * @param signUpRequest : 저장하고자 하는 Preference 정보를 종합적으로 담고 있는 객체 + * @return : 저장한 Preference 반환 (Type, Region 제외) + */ + Preference savePreference(SignUpRequest signUpRequest); + + /** + * 접근한 유저의 취향 정보를 종합해서 조회 + * @return : 유저 취향 정보 데이터 + */ + PreferenceInfoResponse readPreference(); + + /** + * 접근한 유저의 취향 정보를 수정 + * @param request : 수정할 취향 정보들 + * @return : 유저 취향 정보 데이터 (수정 후) + */ + PreferenceInfoResponse updatePreference(PreferenceUpdateRequest request); + +} diff --git a/src/main/java/com/curateme/claco/preference/service/PreferenceServiceImpl.java b/src/main/java/com/curateme/claco/preference/service/PreferenceServiceImpl.java new file mode 100644 index 00000000..f92bf272 --- /dev/null +++ b/src/main/java/com/curateme/claco/preference/service/PreferenceServiceImpl.java @@ -0,0 +1,287 @@ +package com.curateme.claco.preference.service; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +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.transaction.annotation.Transactional; + +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.member.domain.dto.request.SignUpRequest; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; +import com.curateme.claco.preference.domain.dto.request.PreferenceUpdateRequest; +import com.curateme.claco.preference.domain.dto.response.PreferenceInfoResponse; +import com.curateme.claco.preference.domain.entity.Preference; +import com.curateme.claco.preference.domain.entity.RegionPreference; +import com.curateme.claco.preference.domain.entity.TypePreference; +import com.curateme.claco.preference.domain.vo.CategoryPreferenceVO; +import com.curateme.claco.preference.domain.vo.RegionPreferenceVO; +import com.curateme.claco.preference.domain.vo.TypePreferenceVO; +import com.curateme.claco.preference.repository.PreferenceRepository; +import com.curateme.claco.preference.repository.RegionPreferenceRepository; +import com.curateme.claco.preference.repository.TypePreferenceRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.client.RestTemplate; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + * 2024.11.05 이 건 조회 및 수정 메서드 추가 + */ +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class PreferenceServiceImpl implements PreferenceService { + + private final SecurityContextUtil securityContextUtil; + private final MemberRepository memberRepository; + private final PreferenceRepository preferenceRepository; + private final TypePreferenceRepository typePreferenceRepository; + private final RegionPreferenceRepository regionPreferenceRepository; + + @Value("${cloud.ai.url}") + private String URL; + + @Override + public Preference savePreference(SignUpRequest signUpRequest) { + + // 현재 로그인 세션 유저 정보 추출 + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + // AI Server 로 취향 전송하기 + List preferences = signUpRequest.getCategoryPreferences().stream() + .map(pref -> pref.getPreferenceCategory()) + .collect(Collectors.toList()); + + sendPreferencesToAI(member.getId(), preferences); + + // TODO: 개선 필요 + // Preference 생성 + Preference preference = Preference.builder() + .member(member) + .minPrice(signUpRequest.getMinPrice()) + .maxPrice(signUpRequest.getMaxPrice()) + .preference1(signUpRequest.getCategoryPreferences().get(0).getPreferenceCategory()) + .preference2(signUpRequest.getCategoryPreferences().get(1).getPreferenceCategory()) + .preference3(signUpRequest.getCategoryPreferences().get(2).getPreferenceCategory()) + .preference4(signUpRequest.getCategoryPreferences().get(3).getPreferenceCategory()) + .preference5(signUpRequest.getCategoryPreferences().get(4).getPreferenceCategory()) + .build(); + + Preference savePreference = preferenceRepository.save(preference); + + // TypePreference 저장 + signUpRequest.getTypePreferences().stream() + .map(TypePreferenceVO::getPreferenceType) + .map(TypePreference::of) + .forEach(typePreference -> { + typePreference.updatePreference(savePreference); + typePreferenceRepository.save(typePreference); + }); + + // RegionPreference 저장 + signUpRequest.getRegionPreferences().stream() + .map(RegionPreferenceVO::getPreferenceRegion) + .map(RegionPreference::of) + .forEach(regionPreference -> { + regionPreference.updatePreference(savePreference); + regionPreferenceRepository.save(regionPreference); + }); + + return savePreference; + } + + @Override + public PreferenceInfoResponse readPreference() { + + // 현재 로그인 세션 유저 정보 추출(취향 정보 조회) + Member member = memberRepository.findMemberByIdIs(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + Preference preference = member.getPreference(); + + return PreferenceInfoResponse.builder() + .age(member.getAge()) + .gender(member.getGender()) + .maxPrice(preference.getMaxPrice()) + .minPrice(preference.getMinPrice()) + .preferCategories( + Stream.of(preference.getPreference1(), preference.getPreference2(), preference.getPreference3(), + preference.getPreference4(), preference.getPreference5()) + .map(CategoryPreferenceVO::new) + .toList() + ) + .preferTypes(preference.getTypePreferences().stream() + .map(TypePreferenceVO::fromEntity) + .toList() + ) + .preferRegions(preference.getRegionPreferences().stream() + .map(RegionPreferenceVO::fromEntity) + .toList() + ) + .build(); + } + + @Override + public PreferenceInfoResponse updatePreference(PreferenceUpdateRequest request) { + // 현재 로그인 세션 유저 정보 추출(취향 정보 조회) + Member member = memberRepository.findMemberByIdIs(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + // 나이 수정 + member.updateAge(request.getAge()); + // 성별 수정 + member.updateGender(request.getGender()); + + Preference preference = member.getPreference(); + + // 가격 수정 + preference.updateMinPrice(request.getMinPrice()); + preference.updateMaxPrice(request.getMaxPrice()); + + //-- 타입 선호도 교체 시작 --// + List requestTypeList = request.getTypePreferences() + .stream() + .map(TypePreferenceVO::getPreferenceType) + .toList(); + + List currentTypeList = preference.getTypePreferences() + .stream() + .map(TypePreference::getTypeContent) + .toList(); + + // 새로운 타입 선호도 생성 및 저장 + requestTypeList.forEach(requestType -> { + if (!currentTypeList.contains(requestType)) { + TypePreference newType = TypePreference.builder() + .preference(preference) + .typeContent(requestType) + .build(); + TypePreference savedType = typePreferenceRepository.save(newType); + preference.addTypeReference(savedType); + } + }); + + // 기존 타입 선호도 삭제 + Iterator typeIterator = preference.getTypePreferences().iterator(); + while (typeIterator.hasNext()) { + TypePreference typePreference = typeIterator.next(); + if (!requestTypeList.contains(typePreference.getTypeContent())){ + typePreferenceRepository.delete(typePreference); + typeIterator.remove(); + } + } + //-- 타입 선호도 교체 종료 --// + + //-- 지역 선호도 교체 시작 --// + List requestRegionList = request.getRegionPreferences() + .stream() + .map(RegionPreferenceVO::getPreferenceRegion) + .toList(); + + List currentRegionList = preference.getRegionPreferences() + .stream() + .map(RegionPreference::getRegionName) + .toList(); + + // 새로운 지역 선호도 생성 및 저장 + requestRegionList.forEach(requestType -> { + if (!currentRegionList.contains(requestType)) { + RegionPreference newType = RegionPreference.builder() + .preference(preference) + .regionName(requestType) + .build(); + RegionPreference savedType = regionPreferenceRepository.save(newType); + preference.addRegionPreference(savedType); + } + }); + + // 기존 지역 선호도 삭제 + Iterator regionIterator = preference.getRegionPreferences().iterator(); + while (regionIterator.hasNext()) { + RegionPreference regionPreference = regionIterator.next(); + if (!requestRegionList.contains(regionPreference.getRegionName())){ + regionPreferenceRepository.delete(regionPreference); + regionIterator.remove(); + } + } + + //-- 지역 선호도 교체 종료 --// + + return PreferenceInfoResponse.builder() + .age(member.getAge()) + .gender(member.getGender()) + .maxPrice(preference.getMaxPrice()) + .minPrice(preference.getMinPrice()) + .preferCategories( + Stream.of(preference.getPreference1(), preference.getPreference2(), preference.getPreference3(), + preference.getPreference4(), preference.getPreference5()) + .map(CategoryPreferenceVO::new) + .toList() + ) + .preferTypes(preference.getTypePreferences().stream() + .map(TypePreferenceVO::fromEntity) + .toList() + ) + .preferRegions(preference.getRegionPreferences().stream() + .map(RegionPreferenceVO::fromEntity) + .toList() + ) + .build(); + } + + + + public void sendPreferencesToAI(Long userId, List preferences) { + Logger logger = LoggerFactory.getLogger(this.getClass()); + + // Prepare JSON body for Flask API + String FLASK_API_URL = URL + "/users/preferences"; + Map body = new HashMap<>(); + body.put("userId", userId); + body.put("preferences", preferences); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + RestTemplate restTemplate = new RestTemplate(); + try { + ResponseEntity response = restTemplate.exchange(FLASK_API_URL, HttpMethod.POST, requestEntity, String.class); + if (response.getStatusCode().is2xxSuccessful()) { + logger.info("취향 전송이 완료되었습니다: {}", response.getBody()); + } else { + logger.warn("취향 전송에 실패했습니다. Status code: {}", response.getStatusCode()); + } + } catch (Exception e) { + logger.error("취향 전송에 실패했습니다: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/curateme/claco/recommendation/controller/RecommendationController.java b/src/main/java/com/curateme/claco/recommendation/controller/RecommendationController.java new file mode 100644 index 00000000..7d53f2e5 --- /dev/null +++ b/src/main/java/com/curateme/claco/recommendation/controller/RecommendationController.java @@ -0,0 +1,64 @@ +package com.curateme.claco.recommendation.controller; + +import com.curateme.claco.global.annotation.TokenRefreshedCheck; +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertResponseV2; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertResponseV3; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertsResponseV1; +import com.curateme.claco.recommendation.service.RecommendationService; +import io.swagger.v3.oas.annotations.Operation; +import java.util.List; +import lombok.RequiredArgsConstructor; +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; + +@TokenRefreshedCheck +@RestController +@RequestMapping("/api/recommendations") +@RequiredArgsConstructor +public class RecommendationController { + + private final RecommendationService recommendationService; + + @GetMapping("/userbased") + @Operation(summary = "나의 취향 기반 맞춤 추천", description = "기능명세서 화면번호 2.0.0(C)") + public ApiResponse> getConcertRecommendations( + ){ + int topn = 5; + return ApiResponse.ok(recommendationService.getConcertRecommendations(topn)); + } + + @GetMapping("/userbased/searches") + @Operation(summary = "(검색어 없을시)나의 취향 기반 맞춤 추천", description = "기능명세서 화면번호 2.0.0(C)") + public ApiResponse> getConcertRecommendationsSearches( + ){ + int topn = 3; + return ApiResponse.ok(recommendationService.getConcertRecommendations(topn)); + } + + + @GetMapping("/itembased") + @Operation(summary = "최근 좋아요 한 공연 기반 맞춤 추천", description = "기능명세서 화면번호 2.1.0(C)") + public ApiResponse getLikedConcertRecommendations( + ){ + return ApiResponse.ok(recommendationService.getLikedConcertRecommendations()); + } + + @GetMapping("/clacobooks") + @Operation(summary = "유저 취향 기반 클라코북 맞춤 추천", description = "기능명세서 화면번호 2.2.0") + public ApiResponse> getClacoBooksRecommendations( + ){ + return ApiResponse.ok(recommendationService.getClacoBooksRecommendations()); + } + + @GetMapping("/concertbased") + @Operation(summary = "선택한 공연과 비슷한 공연 추천", description = "상세보(이 공연도 마음에 들거에요!)") + public ApiResponse> getSearchedConcertRecommendations( + @RequestParam("concertId") Long concertId + + ){ + return ApiResponse.ok(recommendationService.getSearchedConcertRecommendations(concertId)); + } +} diff --git a/src/main/java/com/curateme/claco/recommendation/domain/dto/RecommendationConcertResponseV2.java b/src/main/java/com/curateme/claco/recommendation/domain/dto/RecommendationConcertResponseV2.java new file mode 100644 index 00000000..76be2838 --- /dev/null +++ b/src/main/java/com/curateme/claco/recommendation/domain/dto/RecommendationConcertResponseV2.java @@ -0,0 +1,27 @@ +package com.curateme.claco.recommendation.domain.dto; + +import com.curateme.claco.review.domain.dto.response.TicketInfoResponse; +import com.curateme.claco.review.domain.dto.response.TicketReviewSummaryResponse; +import com.curateme.claco.concert.domain.dto.response.ConcertClacoBookResponse; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RecommendationConcertResponseV2 { + + private TicketInfoResponse ticketInfoResponse; + private TicketReviewSummaryResponse ticketReviewSummary; + + public static RecommendationConcertResponseV2 from( + TicketInfoResponse ticketInfoResponse, + TicketReviewSummaryResponse ticketReviewSummary + ) { + return new RecommendationConcertResponseV2(ticketInfoResponse, ticketReviewSummary); + } +} diff --git a/src/main/java/com/curateme/claco/recommendation/domain/dto/RecommendationConcertResponseV3.java b/src/main/java/com/curateme/claco/recommendation/domain/dto/RecommendationConcertResponseV3.java new file mode 100644 index 00000000..61061771 --- /dev/null +++ b/src/main/java/com/curateme/claco/recommendation/domain/dto/RecommendationConcertResponseV3.java @@ -0,0 +1,34 @@ +package com.curateme.claco.recommendation.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RecommendationConcertResponseV3 { + + @Schema(description = "좋아요 기록 여부") + private Boolean likedHistory; + + @Schema(description = "키워드 3개") + private List keywords; + + @Schema(description = "추천 결과") + private List recommendationConcertsResponseV1s; + + public void setLikedHistory(Boolean likedHistory) { + this.likedHistory = likedHistory; + } + + public void setRecommendationConcertsResponseV1s(List concertResponse) { + this.recommendationConcertsResponseV1s = concertResponse; + } + +} diff --git a/src/main/java/com/curateme/claco/recommendation/domain/dto/RecommendationConcertsResponseV1.java b/src/main/java/com/curateme/claco/recommendation/domain/dto/RecommendationConcertsResponseV1.java new file mode 100644 index 00000000..ab87dfbf --- /dev/null +++ b/src/main/java/com/curateme/claco/recommendation/domain/dto/RecommendationConcertsResponseV1.java @@ -0,0 +1,50 @@ +package com.curateme.claco.recommendation.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Column; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RecommendationConcertsResponseV1 { + + @Schema(description = "공연 아이디") + private Long id; + + @Schema(description = "공연 제목") + private String prfnm; + + @Schema(description = "공연 포스터 URL") + private String poster; + + @Schema(description = "공연 장르") + private String genrenm; + + @Column(name = "공연 장소") + private String fcltynm; + + @Column(name = "start_date") + private String prfpdfrom; + + @Column(name = "end_date") + private String prfpdto; + + @Schema(name = "공연 좋아요 여부") + private Boolean liked; + + public void setLiked(boolean liked) { + this.liked = liked; + } + + public RecommendationConcertsResponseV1(Long id, String prfnm, String poster, String genrenm, String fcltynm, String prfpdfrom, String prfpdto) { + this.id = id; + this.prfnm = prfnm; + this.poster = poster; + this.genrenm = genrenm; + this.fcltynm = fcltynm; + this.prfpdfrom = prfpdfrom; + this.prfpdto = prfpdto; + } +} diff --git a/src/main/java/com/curateme/claco/recommendation/service/RecommendationService.java b/src/main/java/com/curateme/claco/recommendation/service/RecommendationService.java new file mode 100644 index 00000000..72c0b737 --- /dev/null +++ b/src/main/java/com/curateme/claco/recommendation/service/RecommendationService.java @@ -0,0 +1,17 @@ +package com.curateme.claco.recommendation.service; + +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertResponseV2; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertResponseV3; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertsResponseV1; +import java.util.List; + +public interface RecommendationService { + List getConcertRecommendations(int topn); + RecommendationConcertResponseV3 getLikedConcertRecommendations(); + List getClacoBooksRecommendations(); + + List getSearchedConcertRecommendations(Long concertId); + + + +} diff --git a/src/main/java/com/curateme/claco/recommendation/service/RecommendationServiceImpl.java b/src/main/java/com/curateme/claco/recommendation/service/RecommendationServiceImpl.java new file mode 100644 index 00000000..b188715a --- /dev/null +++ b/src/main/java/com/curateme/claco/recommendation/service/RecommendationServiceImpl.java @@ -0,0 +1,321 @@ +package com.curateme.claco.recommendation.service; + +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.clacobook.repository.ClacoBookRepository; +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.concert.repository.ConcertCategoryRepository; +import com.curateme.claco.concert.repository.ConcertLikeRepository; +import com.curateme.claco.concert.repository.ConcertRepository; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertResponseV2; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertResponseV3; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertsResponseV1; +import com.curateme.claco.review.domain.dto.response.TicketInfoResponse; +import com.curateme.claco.review.domain.dto.response.TicketReviewSummaryResponse; +import com.curateme.claco.review.domain.entity.TicketReview; +import com.curateme.claco.review.repository.TicketReviewRepository; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class RecommendationServiceImpl implements RecommendationService{ + + private final ConcertRepository concertRepository; + private final ConcertLikeRepository concertLikeRepository; + private final ClacoBookRepository clacoBookRepository; + private final TicketReviewRepository ticketReviewRepository; + private final ConcertCategoryRepository concertCategoryRepository; + private final SecurityContextUtil securityContextUtil; + private final MemberRepository memberRepository; + + + @Value("${cloud.ai.url}") + private String URL; + + // 유저 취향 기반 공연 추천 + @Override + public List getConcertRecommendations(int topn) { + String FLASK_API_URL = URL + "/recommendations/users/"; + // 현재 로그인 세션 유저 정보 추출 + Long memberId = securityContextUtil.getContextMemberInfo().getMemberId(); + + String jsonResponse = getConcertsFromFlask(memberId, topn, FLASK_API_URL); + + List concertIds = parseConcertIdsFromJson(jsonResponse); + + return getConcertDetailsV2(concertIds, memberId); + } + + // 최근 좋아요한 공연 기반 추천 + @Override + public RecommendationConcertResponseV3 getLikedConcertRecommendations() { + + Long memberId = securityContextUtil.getContextMemberInfo().getMemberId(); + + Pageable pageable = PageRequest.of(0, 1); + Long concertId = concertLikeRepository.findMostRecentLikedConcert(memberId, pageable) + .getContent().stream().findFirst().orElse(null); + + List keywords; + List recommendedConcerts; + + if (concertId == null) { + // 상위 3개 공연 가져오기 + Pageable pageable2 = PageRequest.of(0, 3); + List concertIds = concertLikeRepository.findTopConcertIdsByLikeCount(pageable2); + + // 첫 번째 콘서트의 ID로 키워드 추출 + if (!concertIds.isEmpty()) { + Long firstConcertId = concertIds.get(0); + keywords = concertCategoryRepository.findCategoryNamesByConcertId(firstConcertId).stream() + .limit(3) + .collect(Collectors.toList()); + } else { + keywords = Collections.emptyList(); + } + + recommendedConcerts = getConcertDetails(concertIds); + } else { + // Flask API 호출하여 추천 데이터 가져오기 + String FLASK_API_URL = URL + "/recommendations/items/"; + int topn = 2; + String jsonResponse = getConcertsFromFlask(concertId, topn, FLASK_API_URL); + + List concertIds = parseConcertIdsFromJson(jsonResponse); + + recommendedConcerts = getConcertDetails(concertIds); + + // Use the keywords from the liked concert + keywords = concertCategoryRepository.findCategoryNamesByConcertId(concertId).stream() + .limit(3) + .collect(Collectors.toList()); + } + + return RecommendationConcertResponseV3.builder() + .likedHistory(concertId != null) + .keywords(keywords) + .recommendationConcertsResponseV1s(recommendedConcerts) + .build(); + } + + // 유저 취향 기반 클라코북 추천 + @Override + public List getClacoBooksRecommendations() { + + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + // Flask API call + String FLASK_API_URL = URL + "/recommendations/clacobooks/"; + String jsonResponse = getConcertsFromFlaskV2(member.getId(), FLASK_API_URL); + + List recUserIds = parseConcertIdsFromJson(jsonResponse).stream() + .limit(3) + .collect(Collectors.toList()); + + List recommendationResponses = new ArrayList<>(); + + for (Long recUserId : recUserIds) { + List clacoBooks = clacoBookRepository.findByMemberId(recUserId); + + if (clacoBooks.isEmpty()) { + continue; + } + + for (ClacoBook clacoBook : clacoBooks) { + TicketReview ticketReview = clacoBookRepository.findRandomTicketReviewsByClacoBookId(clacoBook.getId()) + .stream() + .findFirst() + .orElse(null); + + if (ticketReview == null) { + continue; + } + + TicketReviewSummaryResponse ticketReviewSummaryResponse = ticketReviewRepository.findSummaryById(ticketReview.getId()); + + TicketInfoResponse ticketInfoResponse = TicketInfoResponse.fromEntity(ticketReview); + + recommendationResponses.add( + RecommendationConcertResponseV2.from(ticketInfoResponse, ticketReviewSummaryResponse) + ); + } + } + + if (recommendationResponses.isEmpty()) { + ClacoBook randomClacoBook = clacoBookRepository.findRandomClacoBookWithThreeOrMoreReviews() + .orElseThrow(() -> new BusinessException(ApiStatus.CLACO_BOOK_NOT_FOUND)); + + List randomTicketReviews = clacoBookRepository.findRandomTicketReviewsByClacoBookId(randomClacoBook.getId()); + + int limit = Math.min(3, randomTicketReviews.size()); + for (int i = 0; i < limit; i++) { + TicketReview ticketReview = randomTicketReviews.get(i); + + TicketReviewSummaryResponse ticketReviewSummaryResponse = ticketReviewRepository.findSummaryById(ticketReview.getId()); + + TicketInfoResponse ticketInfoResponse = TicketInfoResponse.fromEntity(ticketReview); + + recommendationResponses.add( + RecommendationConcertResponseV2.from(ticketInfoResponse, ticketReviewSummaryResponse) + ); + } + } + + return recommendationResponses.stream() + .limit(3) + .collect(Collectors.toList()); + } + + + + @Override + public List getSearchedConcertRecommendations(Long concertId) { + + List recommendedConcerts; + + String FLASK_API_URL = URL + "/recommendations/items/"; + int topn = 3; + String jsonResponse = getConcertsFromFlask(concertId, topn, FLASK_API_URL); + + List concertIds = parseConcertIdsFromJson(jsonResponse); + + recommendedConcerts = getConcertDetails(concertIds); + + return recommendedConcerts; + } + + + // JSON 응답을 파싱하여 concertIds 리스트 생성 + public List parseConcertIdsFromJson(String jsonResponse) { + List concertIds = new ArrayList<>(); + if (jsonResponse != null) { + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(jsonResponse); + JsonNode recommendationsArray = root.get("recommendations"); + + for (JsonNode recommendation : recommendationsArray) { + Long concertId = Long.parseLong(recommendation.get(0).asText()); + concertIds.add(concertId); + } + } catch (Exception e) { + } + } + return concertIds; + } + + // concertIds를 기반으로 콘서트 정보를 조회하여 recommendations 리스트 생성 + public List getConcertDetails(List concertIds) { + List recommendations = new ArrayList<>(); + + for (Long concertId : concertIds) { + Concert concert = concertRepository.findConcertById(concertId); + Long id = concert.getId(); + + recommendations.add(new RecommendationConcertsResponseV1( + id, + concert.getPrfnm(), + concert.getPoster(), + concert.getGenrenm(), + concert.getFcltynm(), + concert.getPrfpdfrom().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd (EEEE)", Locale.KOREAN)), + concert.getPrfpdto().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd (EEEE)", Locale.KOREAN)) + )); + } + return recommendations; + } + + public List getConcertDetailsV2(List concertIds, Long memberId) { + List recommendations = new ArrayList<>(); + + for (Long concertId : concertIds) { + Concert concert = concertRepository.findConcertById(concertId); + Long id = concert.getId(); + + boolean liked = concertLikeRepository.existsByConcertIdAndMemberId(concert.getId(), memberId); + + RecommendationConcertsResponseV1 recommendation = new RecommendationConcertsResponseV1( + id, + concert.getPrfnm(), + concert.getPoster(), + concert.getGenrenm(), + concert.getFcltynm(), + concert.getPrfpdfrom().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd (EEEE)", Locale.KOREAN)), + concert.getPrfpdto().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd (EEEE)", Locale.KOREAN)) + ); + + recommendation.setLiked(liked); + recommendations.add(recommendation); + } + return recommendations; + } + + public String getConcertsFromFlask(Long Id, int topn, String FLASK_API_URL) { + String urlWithUserId = FLASK_API_URL + Id + "/" + topn; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> requestEntity = new HttpEntity<>(headers); + + RestTemplate restTemplate = new RestTemplate(); + try { + ResponseEntity response = restTemplate.exchange(urlWithUserId, HttpMethod.GET, requestEntity, String.class); + if (response.getStatusCode().is2xxSuccessful()) { + return response.getBody(); + } else { + return response.getStatusCode().toString(); + } + } catch (Exception e) { + return e.getMessage(); + } + } + + public String getConcertsFromFlaskV2(Long Id, String FLASK_API_URL) { + String urlWithUserId = FLASK_API_URL + Id; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> requestEntity = new HttpEntity<>(headers); + + RestTemplate restTemplate = new RestTemplate(); + try { + ResponseEntity response = restTemplate.exchange(urlWithUserId, HttpMethod.GET, requestEntity, String.class); + if (response.getStatusCode().is2xxSuccessful()) { + return response.getBody(); + } else { + return response.getStatusCode().toString(); + } + } catch (Exception e) { + return e.getMessage(); + } + } + +} diff --git a/src/main/java/com/curateme/claco/review/controller/PlaceCategoryController.java b/src/main/java/com/curateme/claco/review/controller/PlaceCategoryController.java new file mode 100644 index 00000000..e744c936 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/controller/PlaceCategoryController.java @@ -0,0 +1,33 @@ +package com.curateme.claco.review.controller; + +import java.util.List; +import java.util.Map; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.curateme.claco.global.annotation.TokenRefreshedCheck; +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.review.domain.dto.response.CategoryListResponse; +import com.curateme.claco.review.domain.vo.PlaceCategoryVO; +import com.curateme.claco.review.service.PlaceCategoryService; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +@TokenRefreshedCheck +@RestController +@RequestMapping("/api/place-categories") +@RequiredArgsConstructor +public class PlaceCategoryController { + + private final PlaceCategoryService placeCategoryService; + + @GetMapping + @Operation(summary = "장소평 카테고리 조회", description = "장소평의 카테고리 조회") + public ApiResponse>> readPlaceCategoryList() { + return ApiResponse.ok(new CategoryListResponse<>(placeCategoryService.readPlaceCategoryList())); + } + +} diff --git a/src/main/java/com/curateme/claco/review/controller/TagCategoryController.java b/src/main/java/com/curateme/claco/review/controller/TagCategoryController.java new file mode 100644 index 00000000..c9c8c558 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/controller/TagCategoryController.java @@ -0,0 +1,33 @@ +package com.curateme.claco.review.controller; + +import java.util.List; +import java.util.Map; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.curateme.claco.global.annotation.TokenRefreshedCheck; +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.review.domain.dto.response.CategoryListResponse; +import com.curateme.claco.review.domain.vo.TagCategoryVO; +import com.curateme.claco.review.service.TagCategoryService; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +@TokenRefreshedCheck +@RestController +@RequestMapping("/api/tag-categories") +@RequiredArgsConstructor +public class TagCategoryController { + + private final TagCategoryService tagCategoryService; + + @GetMapping + @Operation(summary = "공연 성격의 카테고리 조회", description = "공연 성격의 카테고리 조회") + public ApiResponse>> readTagCategoryList() { + return ApiResponse.ok(new CategoryListResponse<>(tagCategoryService.readTagCategoryList())); + } + +} diff --git a/src/main/java/com/curateme/claco/review/controller/TicketReviewController.java b/src/main/java/com/curateme/claco/review/controller/TicketReviewController.java new file mode 100644 index 00000000..25648457 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/controller/TicketReviewController.java @@ -0,0 +1,231 @@ +package com.curateme.claco.review.controller; + +import java.io.IOException; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.curateme.claco.global.annotation.TokenRefreshedCheck; +import com.curateme.claco.global.response.ApiResponse; +import com.curateme.claco.review.domain.dto.TicketReviewUpdateDto; +import com.curateme.claco.review.domain.dto.request.OrderBy; +import com.curateme.claco.review.domain.dto.request.TicketReviewCreateRequest; +import com.curateme.claco.review.domain.dto.response.CountResponse; +import com.curateme.claco.review.domain.dto.response.ReviewInfoResponse; +import com.curateme.claco.review.domain.dto.response.ReviewListResponse; +import com.curateme.claco.review.domain.dto.response.TicketListResponse; +import com.curateme.claco.review.domain.dto.response.TicketReviewInfoResponse; +import com.curateme.claco.review.domain.vo.ImageUrlVO; +import com.curateme.claco.review.service.TicketReviewServiceImpl; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; + +@TokenRefreshedCheck +@RestController +@RequestMapping("/api/ticket-reviews") +@RequiredArgsConstructor +public class TicketReviewController { + + private final TicketReviewServiceImpl ticketReviewService; + + /** + * TicketReview 생성 + * @param request : 생성 정보, 클라코북의 경우 초기 생성이라면 null + * @param files : 리뷰 이미지 배열 + * @return : 생성한 TicketReview 정보 + */ + @PostMapping(consumes = {"multipart/form-data"}) + @Operation(summary = "티켓 초기 생성 (리뷰)", description = "리뷰 입력과 함께 티켓 초기 생성") + @Parameter(name = "request", description = "생성하고자 하는 요청 본문, form-data text로 보내주세요.") + @Parameter(name = "files", description = "MultipartFile 배열, 여러 이미지 파일 보내기 가능") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TCK-010", description = "이미지 최대 개수 초과(최대 3)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "사용자를 찾을 수 없음(잘못된 토큰 요청)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "CLB-001", description = "요청한 클라코북을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "SZE-001", description = "클라코북에 저장 가능한 티켓 수를 넘음(최대 20)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "CON-001", description = "저장하고자 하는 콘서트를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PLC-001", description = "요청한 장소평을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "CTG-001", description = "요청한 공연 성격을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "IMG-010", description = "이미지 업로드에 실패함(S3 연결 확인)"), + }) + public ApiResponse createTicketReview( + @Validated @RequestPart("request") TicketReviewCreateRequest request, + @RequestPart(value = "files", required = false) MultipartFile[] files) throws IOException { + + return ApiResponse.ok(ticketReviewService.createTicketReview(request, files == null ? new MultipartFile[]{} : files)); + } + + /** + * 티켓 이미지 업로드 + * @param id : 티켓 이미지를 저장할 TicketReview id + * @param file : 이미지 파일 + * @return : 저장한 이미지 url + */ + @PutMapping(value = "/ticket-images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "티켓 이미지 저장(티켓리뷰 생성 후)", description = "티켓 이미지 저장 후 이미지 Url 추출") + @Parameter(name = "id", description = "티켓 이미지가 저장될 티켓리뷰 id") + @Parameter(name = "file", description = "티켓 이미지 MultipartFile") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TCK-001", description = "티켓리뷰를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "사용자를 찾을 수 없음(잘못된 토큰 요청)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-999", description = "해당 리소스의 소유주가 아님") + }) + public ApiResponse addNewTicketImage( + @RequestParam("id") Long id, + @RequestPart("file") MultipartFile file) throws IOException { + + return ApiResponse.ok(ticketReviewService.addNewTicket(id, file)); + } + + /** + * 단일 리뷰 상세 조회 + * @param id : 조회하고자 하는 리뷰 id + * @return : 리뷰의 정보 + */ + @GetMapping("/reviews/{reviewId}") + @Operation(summary = "단일 리뷰 상세 조회", description = "단일 리뷰 상세 조회(티켓리뷰 아님)") + @Parameter(name = "reviewId", description = "상세 조회할 리뷰 id") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TCK-001", description = "티켓리뷰를 찾을 수 없음") + }) + public ApiResponse readDetailReview(@PathVariable("reviewId") Long id) { + return ApiResponse.ok(ticketReviewService.readReview(id)); + } + + /** + * TicketReview 정보 상세 조회(공연 정보 포함) + * @param id : 조회하고자 하는 TicketReview id + * @return : TickerReview 전체 정보 + */ + @GetMapping("/{ticketReviewId}") + @Operation(summary = "티켓리뷰 상세 조회 (공연 정보도 같이)", description = "티켓 리뷰 상세 조회 (공연 정보도 같이)") + @Parameter(name = "ticketReviewId", description = "상세 조회할 티켓리뷰 id") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TCK-001", description = "티켓리뷰를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "사용자를 찾을 수 없음(잘못된 토큰 요청)") + }) + public ApiResponse readTicketReview(@PathVariable("ticketReviewId") Long id) { + return ApiResponse.ok(ticketReviewService.readTicketReview(id)); + } + + /** + * 공연의 리뷰 리스트 조회 + * @param concertId : 조회하고자 하는 공연 id + * @param page : 페이지 번호 (1부터 시작) + * @param size : 한 페이지의 크기 (10개 초과시 오류) + * @param orderBy : 정렬 조건 (별점 높은 순, 낮은 순, 최신순) + * @return : 총 페이지 개수, 현재 페이지 번호, 요청 사이즈, 페이징 된 데이터 + */ + @GetMapping("/concerts/reviews/{concertId}") + @Operation(summary = "공연의 리뷰 리스트 조회", description = "공연에 작성된 리뷰 리스트 조회") + @Parameter(name = "concertId", description = "조회할 공연의 id") + @Parameter(name = "page", description = "조회할 페이지 (1부터 시작)") + @Parameter(name = "size", description = "한 페이지의 개수(최대 10)") + @Parameter(name = "orderBy", description = "정렬 조건, HIGH_RATE / LOW_RATE / RECENT", example = "RECENT") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "CON-001", description = "콘서트를 찾을 수 없음") + }) + public ApiResponse readReviewOfConcert(@PathVariable("concertId") Long concertId, + @Validated @Min(value = 1) @RequestParam("page") Integer page, + @Validated @Max(value = 10) @RequestParam("size") Integer size, + @RequestParam("orderBy")OrderBy orderBy + ) { + return ApiResponse.ok(ticketReviewService.readReviewOfConcert(concertId, page - 1, size, orderBy)); + } + + /** + * 클라코북에 속한 티켓 조회 + * @param id : 조회하고자 하는 클라코북 id + * @return : 티켓 아이디, 티켓 이미지 + */ + @GetMapping("/claco-books/{clacoBookId}") + @Operation(summary = "클라코북에 속한 티켓 리스트 조회", description = "클라코북에 속한 티켓 리스트(이미지)조회") + @Parameter(name = "clacoBookId", description = "조회할 클라코북 id") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "CLB-001", description = "요청한 클라코북을 찾을 수 없음") + }) + public ApiResponse readTicketListFromClacoBook(@PathVariable("clacoBookId") Long id) { + return ApiResponse.ok(ticketReviewService.readTicketOfClacoBook(id)); + } + + /** + * 공연의 총 리뷰 개수 + * @param id : 조회하고자 하는 공연 id + * @return : 리뷰 총 개수 + */ + @GetMapping("/concerts/{concertId}/size") + @Operation(summary = "공연의 리뷰 총 개수", description = "공연의 리뷰 총 개수") + @Parameter(name = "concertId", description = "조회할 공연 id") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "CON-001", description = "콘서트를 찾을 수 없음") + }) + public ApiResponse countReviewOfConcert(@PathVariable("concertId") Long id) { + return ApiResponse.ok(new CountResponse(ticketReviewService.countReview(id))); + } + + @PutMapping + @Operation(summary = "티켓리뷰 수정", description = "티켓리뷰 수정(관람 좌석, 별점, 감상평)") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TCK-001", description = "티켓리뷰를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "사용자를 찾을 수 없음(잘못된 토큰 요청)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-999", description = "해당 리소스의 소유주가 아님") + }) + public ApiResponse updateTicketReview(@RequestBody TicketReviewUpdateDto reviewUpdateDto) { + return ApiResponse.ok(ticketReviewService.editTicketReview(reviewUpdateDto)); + } + + /** + * TickerReview 삭제 (Cascade) + * @param id : 삭제하고자 하는 TickerReview id + * @return : Void + */ + @DeleteMapping("/{ticketReviewId}") + @Operation(summary = "티켓리뷰 삭제", description = "티켓리뷰 삭제") + @Parameter(name = "ticketReviewId", description = "삭제할 티켓리뷰") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COM-000", description = "정상 응답"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TCK-001", description = "티켓리뷰를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-001", description = "사용자를 찾을 수 없음(잘못된 토큰 요청)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEM-999", description = "해당 리소스의 소유주가 아님") + }) + public ApiResponse deleteTicketReview(@PathVariable("ticketReviewId") Long id) { + ticketReviewService.deleteTicket(id); + return ApiResponse.ok(); + } + + @PutMapping("/{ticketReviewId}/claco-books/{clacoBookId}") + @Operation(summary = "티켓리뷰 이동", description = "티켓리뷰 클라코북 이동") + @Parameter(name = "ticketReviewId", description = "이동 대상 티켓리뷰 id") + @Parameter(name = "clacoBookId", description = "이동 대상 클라코북 id") + public ApiResponse moveTicketReview(@PathVariable("ticketReviewId") Long ticketReviewId, + @PathVariable("clacoBookId") Long clacoBookId) { + ticketReviewService.moveTicketReview(ticketReviewId, clacoBookId); + return ApiResponse.ok(); + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/TicketReviewUpdateDto.java b/src/main/java/com/curateme/claco/review/domain/dto/TicketReviewUpdateDto.java new file mode 100644 index 00000000..1e71ff43 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/TicketReviewUpdateDto.java @@ -0,0 +1,50 @@ +package com.curateme.claco.review.domain.dto; + +import java.math.BigDecimal; + +import com.curateme.claco.review.domain.entity.TicketReview; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.04 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.04 이 건 최초 생성 + * 2024.11.05 이 건 Swagger 적용 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TicketReviewUpdateDto { + + // TicketReview id + @NotNull + @Schema(description = "티켓리뷰 id", example = "1") + private Long ticketReviewId; + // 관람 좌석 + @Schema(description = "관람 좌석", example = "1층 2열") + private String watchSit; + // 별점 + @Schema(description = "별점", example = "1.5") + private BigDecimal starRate; + // 감상평 + @Schema(description = "감상평", example = "공연이 재미있어요.") + private String content; + + public static TicketReviewUpdateDto fromEntity(TicketReview ticketReview) { + return new TicketReviewUpdateDto(ticketReview.getId(), ticketReview.getWatchSit(), ticketReview.getStarRate(), + ticketReview.getContent()); + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/request/OrderBy.java b/src/main/java/com/curateme/claco/review/domain/dto/request/OrderBy.java new file mode 100644 index 00000000..67a2f036 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/request/OrderBy.java @@ -0,0 +1,22 @@ +package com.curateme.claco.review.domain.dto.request; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.04 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.04 이 건 최초 생성 + */ +@Getter +@RequiredArgsConstructor +public enum OrderBy { + // 별점 높은 순, 낮은 순, 최근순 + HIGH_RATE("star_rate desc"), LOW_RATE("star_rate asc"), RECENT("created_at desc"); + private final String orderBy; + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/request/TicketReviewCreateRequest.java b/src/main/java/com/curateme/claco/review/domain/dto/request/TicketReviewCreateRequest.java new file mode 100644 index 00000000..bf63222a --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/request/TicketReviewCreateRequest.java @@ -0,0 +1,79 @@ +package com.curateme.claco.review.domain.dto.request; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import com.curateme.claco.review.domain.vo.PlaceCategoryVO; +import com.curateme.claco.review.domain.vo.TagCategoryVO; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.04 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.04 이 건 최초 생성 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TicketReviewCreateRequest { + + // 공연 id + @NotNull + @Schema(description = "관람한 공연 id", example = "1") + private Long concertId; + // 만약 초기 생성이라면 null + @Schema(description = "클라코북 Id(클라코북 생성 전이면 null)", example = "1") + private Long clacoBookId; + // 관람 날짜 + @NotNull + @Schema(description = "관람 날짜", example = "2024-11-05") + private LocalDate watchDate; + // 관람 회차 + @NotNull + @Schema(description = "관람 회차", example = "17:00") + private String watchRound; + // 관람 좌석 + @Schema(description = "관람 좌석", example = "1층 C열 25번") + private String watchSit; + // 별점 + @NotNull + @Max(5) @Min(0) + @Schema(description = "별점 (소수점 첫째자리까지)", example = "3.5", minProperties = 0, maxProperties = 5) + private BigDecimal starRate; + // 캐스팅 + @NotNull + @Schema(description = "캐스팅 문자열", example = "고길동, 고희동, 둘리") + private String casting; + // 감상평 + @NotNull + @Size(max = 500) + @Schema(description = "감상평 본문", example = "공연이 웅장하고 좋았습니다. 추천해요.", maxLength = 500) + private String content; + // 장소평 + @NotNull + @Size(min = 5, max = 5) + @Schema(description = "장소평 리스트", minLength = 5, maxLength = 5) + private List placeReviewIds; + // 공연 감상 태그 + @NotNull + @Size(min = 5, max = 5) + @Schema(description = "공연 감상 리스트", minLength = 5, maxLength = 5) + private List tagCategoryIds; + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/response/CategoryListResponse.java b/src/main/java/com/curateme/claco/review/domain/dto/response/CategoryListResponse.java new file mode 100644 index 00000000..0cda25e8 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/response/CategoryListResponse.java @@ -0,0 +1,27 @@ +package com.curateme.claco.review.domain.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.05 + * @author devkeon(devkeon123 @ gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.05 이 건 최초 생성 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CategoryListResponse { + + private T categories; + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/response/CountResponse.java b/src/main/java/com/curateme/claco/review/domain/dto/response/CountResponse.java new file mode 100644 index 00000000..37497f7f --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/response/CountResponse.java @@ -0,0 +1,26 @@ +package com.curateme.claco.review.domain.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.05 + * @author devkeon(devkeon123 @ gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.05 이 건 최초 생성 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CountResponse { + + private Integer total; + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/response/ReviewInfoResponse.java b/src/main/java/com/curateme/claco/review/domain/dto/response/ReviewInfoResponse.java new file mode 100644 index 00000000..3bf63f18 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/response/ReviewInfoResponse.java @@ -0,0 +1,120 @@ +package com.curateme.claco.review.domain.dto.response; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import com.curateme.claco.review.domain.entity.PlaceReview; +import com.curateme.claco.review.domain.entity.ReviewTag; +import com.curateme.claco.review.domain.entity.TicketReview; +import com.curateme.claco.review.domain.vo.ImageUrlVO; +import com.curateme.claco.review.domain.vo.PlaceCategoryVO; +import com.curateme.claco.review.domain.vo.TagCategoryVO; +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.04 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.04 이 건 최초 생성 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReviewInfoResponse { + + // 생성한 TicketReview id + @Schema(description = "티켓리뷰Id", example = "1") + private Long ticketReviewId; + // 작성자 이름 + @Schema(description = "작성자 이름", example = "고길동") + private String userName; + // 작성자 프사 + @Schema(description = "작성자 프사", example = "https://claco.com") + private String profileImage; + // 리뷰 남긴 일자 + @Schema(description = "리뷰 남긴 일자") + private LocalDate createdDate; + // 관람 좌석 + @Schema(description = "관람 좌석", example = "1층 2열") + private String watchSit; + // 별점 + @Schema(description = "별점", example = "3.5") + private BigDecimal starRate; + // 리뷰 내용 + @Schema(description = "뷰 내용", example = "공연이 재미있어요") + private String content; + // 이미지 url 리스트 + @Schema(description = "이미지 url 리스트") + private List reviewImages; + // 장소평 리스트 + @Schema(description = "장소평 리스트") + private List placeReviews; + // 공연 태그 리스트 + @Schema(description = "공연 성격 리스트, 이 부분은 리뷰 리스트 조회 시에는 안보임, 리뷰 상세 조회시에만 확인 가능") + private List tagReviews; + + // 공연 성격 카테고리 제외 리뷰 + public static ReviewInfoResponse fromEntityToSimpleReview(TicketReview ticketReview) { + return ReviewInfoResponse.builder() + .ticketReviewId(ticketReview.getId()) + .userName(ticketReview.getMember().getNickname()) + .profileImage(ticketReview.getMember().getProfileImage()) + .createdDate(LocalDate.from(ticketReview.getCreatedAt())) + .watchSit(ticketReview.getWatchSit()) + .starRate(ticketReview.getStarRate()) + .content(ticketReview.getContent()) + .reviewImages( + ticketReview.getReviewImages().stream() + .map(ImageUrlVO::fromReviewImage) + .toList() + ) + .placeReviews(ticketReview.getPlaceReviews().stream() + .map(PlaceReview::getPlaceCategory) + .map(PlaceCategoryVO::fromEntity) + .toList() + ) + .build(); + } + + // 공연 카테고리 포함 리뷰 (상세보기) + public static ReviewInfoResponse fromEntityToDetailReview(TicketReview ticketReview) { + return ReviewInfoResponse.builder() + .ticketReviewId(ticketReview.getId()) + .userName(ticketReview.getMember().getNickname()) + .profileImage(ticketReview.getMember().getProfileImage()) + .createdDate(LocalDate.from(ticketReview.getCreatedAt())) + .watchSit(ticketReview.getWatchSit()) + .starRate(ticketReview.getStarRate()) + .content(ticketReview.getContent()) + .reviewImages( + ticketReview.getReviewImages().stream() + .map(ImageUrlVO::fromReviewImage) + .toList() + ) + .placeReviews(ticketReview.getPlaceReviews().stream() + .map(PlaceReview::getPlaceCategory) + .map(PlaceCategoryVO::fromEntity) + .toList() + ) + .tagReviews(ticketReview.getReviewTags().stream() + .map(ReviewTag::getTagCategory) + .map(TagCategoryVO::fromEntity) + .toList() + ) + .build(); + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/response/ReviewListResponse.java b/src/main/java/com/curateme/claco/review/domain/dto/response/ReviewListResponse.java new file mode 100644 index 00000000..d03c7277 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/response/ReviewListResponse.java @@ -0,0 +1,41 @@ +package com.curateme.claco.review.domain.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.04 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.04 이 건 최초 생성 + * 2024.11.05 이 건 Swagger 적용 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReviewListResponse { + + // 총 페이지 수 + @Schema(description = "총 페이지 수", example = "3") + private Integer totalPage; + // 현재 페이지 + @Schema(description = "현재 페이지", example = "1") + private Integer currentPage; + // 요청 페이지 크기 + @Schema(description = "요청한 한 페이지의 크기", example = "5") + private Integer size; + // 리뷰 정보 리스트 (페이징 된) + @Schema(description = "요청 리뷰 정보 리스트") + private List reviewList; + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/response/TicketInfoResponse.java b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketInfoResponse.java new file mode 100644 index 00000000..26f3898e --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketInfoResponse.java @@ -0,0 +1,37 @@ +package com.curateme.claco.review.domain.dto.response; + +import com.curateme.claco.review.domain.entity.TicketReview; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.04 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.04 이 건 최초 생성 + * 2024.11.05 이 건 Swagger 적용 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TicketInfoResponse { + + @Schema(description = "티켓의 Id", example = "1") + private Long id; + @Schema(description = "티켓의 이미지 url", example = "https://claco.com") + private String ticketImage; + + public static TicketInfoResponse fromEntity(TicketReview ticketReview) { + return new TicketInfoResponse(ticketReview.getId(), ticketReview.getTicketImage()); + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/response/TicketListResponse.java b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketListResponse.java new file mode 100644 index 00000000..6109622e --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketListResponse.java @@ -0,0 +1,31 @@ +package com.curateme.claco.review.domain.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.04 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.04 이 건 최초 생성 + * 2024.11.05 이 건 Swagger 적용 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TicketListResponse { + + @Schema(description = "티켓 정보 리스트") + private List ticketList; + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/response/TicketReviewInfoResponse.java b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketReviewInfoResponse.java new file mode 100644 index 00000000..1ba4d8a6 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketReviewInfoResponse.java @@ -0,0 +1,133 @@ +package com.curateme.claco.review.domain.dto.response; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import com.curateme.claco.review.domain.entity.PlaceReview; +import com.curateme.claco.review.domain.entity.ReviewTag; +import com.curateme.claco.review.domain.entity.TicketReview; +import com.curateme.claco.review.domain.vo.ImageUrlVO; +import com.curateme.claco.review.domain.vo.PlaceCategoryVO; +import com.curateme.claco.review.domain.vo.TagCategoryVO; +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * @author : 이 건 + * @date : 2024.11.04 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.04 이 건 최초 생성 + * 2024.11.05 이 건 Swagger 적용 + */ +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class TicketReviewInfoResponse { + + // 티켓 리뷰 id + @Schema(description = "티켓리뷰 Id", example = "1") + private Long ticketReviewId; + // 콘서트 명 + @Schema(description = "콘서트 명", example = "오페라의 유령") + private String concertName; + @Schema(description = "닉네임, create 응답에는 미포함", example = "사용자1") + private String nickname; + // 관람 날짜 + @Schema(description = "관람 날짜") + private LocalDate watchDate; + // 리뷰 남긴 날짜 + @Schema(description = "리뷰 남긴 날짜") + private LocalDate createdDate; + // 관람 위치(공연장) + @Schema(description = "관람 위치(공연장)", example = "예술의 전당") + private String watchPlace; + // 관람 회차 + @Schema(description = "관람 회차", example = "17:00") + private String watchRound; + // 러닝 타임 + @Schema(description = "러닝 타임", example = "150분") + private String runningTime; + // 캐스팅 + @Schema(description = "캐스팅", example = "고길동, 고희동") + private String castings; + // 관람 좌석 + @Schema(description = "관람 좌석", example = "1층 3열") + private String watchSit; + @Schema(description = "티켓 이미지 url", example = "https://claco-server.com/image") + private String ticketImage; + // 관람 태그(공연 성격) + @Schema(description = "공연 성격들") + private List concertTags; + // 별점 + @Schema(description = "별점", example = "3.5") + private BigDecimal starRate; + // 관람평(본문) + @Schema(description = "감상평", example = "공연이 재미있어요.") + private String content; + @Schema(description = "클라코 북 id", example = "1") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Long clacoBookId; + @Schema(description = "콘서트 장르 이름", example = "무용") + private String genreName; + @Schema(description = "공연 중인 여부", example = "공연 중") + private String concertState; + // 장소평 + @Schema(description = "장소평들") + private List placeReviews; + // 리뷰 이미지 + @Schema(description = "리뷰 이미지들") + private List imageUrlS; + @Builder.Default + @Schema(description = "티켓 리뷰 소유주") + private Boolean editor = true; + + public void updateClacoBookId(Long clacoBookId) { + this.clacoBookId = clacoBookId; + } + + public static TicketReviewInfoResponse fromTicketReview(TicketReview ticketReview) { + TicketReviewInfoResponse response = new TicketReviewInfoResponse(); + response.ticketReviewId = ticketReview.getId(); + response.nickname = ticketReview.getMember().getNickname(); + response.watchDate = ticketReview.getWatchDate(); + response.watchRound = ticketReview.getWatchRound(); + response.watchSit = ticketReview.getWatchSit(); + response.ticketImage = ticketReview.getTicketImage(); + response.concertTags = ticketReview.getReviewTags().stream() + .map(ReviewTag::getTagCategory) + .map(TagCategoryVO::fromEntity) + .toList(); + response.starRate = ticketReview.getStarRate(); + response.content = ticketReview.getContent(); + response.placeReviews = ticketReview.getPlaceReviews() + .stream() + .map(PlaceReview::getPlaceCategory) + .map(PlaceCategoryVO::fromEntity) + .toList(); + response.imageUrlS = ticketReview.getReviewImages().stream().map(ImageUrlVO::fromReviewImage).toList(); + response.concertName = ticketReview.getConcert().getPrfnm(); + response.watchPlace = ticketReview.getConcert().getFcltynm(); + response.runningTime = ticketReview.getConcert().getPrfruntime(); + response.castings = ticketReview.getCasting(); + response.createdDate = LocalDate.from(ticketReview.getCreatedAt()); + response.genreName = ticketReview.getConcert().getGenrenm(); + response.concertState = ticketReview.getConcert().getPrfstate(); + + return response; + } + + + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/response/TicketReviewSimpleResponse.java b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketReviewSimpleResponse.java new file mode 100644 index 00000000..c183489a --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketReviewSimpleResponse.java @@ -0,0 +1,40 @@ +package com.curateme.claco.review.domain.dto.response; + +import com.curateme.claco.review.domain.entity.TicketReview; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class TicketReviewSimpleResponse { + + @Schema(description = "티켓리뷰 Id", example = "1") + private Long ticketReviewId; + + @Schema(description = "닉네임, create 응답에는 미포함", example = "사용자1") + private String nickname; + + @Schema(description = "별점", example = "3.5") + private BigDecimal starRate; + + @Schema(description = "감상평", example = "공연이 재미있어요.") + private String content; + + public static TicketReviewSimpleResponse fromEntity(TicketReview ticketReview) { + return TicketReviewSimpleResponse.builder() + .ticketReviewId(ticketReview.getId()) + .nickname(ticketReview.getMember().getNickname()) + .starRate(ticketReview.getStarRate()) + .content(ticketReview.getContent()) + .build(); + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/dto/response/TicketReviewSummaryResponse.java b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketReviewSummaryResponse.java new file mode 100644 index 00000000..87e706e9 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/dto/response/TicketReviewSummaryResponse.java @@ -0,0 +1,33 @@ +package com.curateme.claco.review.domain.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +public class TicketReviewSummaryResponse { + + @Schema(name = "사용자 닉네임") + private String nickName; + + @Schema(name = "공연 제목") + private String concertName; + + @Schema(name = "공연 아이디") + private Long concertId; + + @Schema(name = "티켓 등록 날짜(관람 날짜)") + private LocalDateTime createdAt; + + @Schema(name = "리뷰 내용") + private String content; + + // Constructor + public TicketReviewSummaryResponse(String nickName, String concertName, Long concertId, LocalDateTime createdAt, String content) { + this.nickName = nickName; + this.concertName = concertName; + this.concertId = concertId; + this.createdAt = createdAt; + this.content = content; + } +} diff --git a/src/main/java/com/curateme/claco/review/domain/entity/PlaceCategory.java b/src/main/java/com/curateme/claco/review/domain/entity/PlaceCategory.java new file mode 100644 index 00000000..3d7b621c --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/entity/PlaceCategory.java @@ -0,0 +1,43 @@ +package com.curateme.claco.review.domain.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.global.entity.BaseEntity; + +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; + +/** + * @author : 이 건 + * @date : 2024.10.28 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.28 이 건 최초 생성 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE place_category SET active_status = 'DELETED' WHERE place_category_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class PlaceCategory extends BaseEntity { + + @Id @Column(name = "place_category_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + // 카테고리 이름 + private String name; + +} diff --git a/src/main/java/com/curateme/claco/review/domain/entity/PlaceReview.java b/src/main/java/com/curateme/claco/review/domain/entity/PlaceReview.java new file mode 100644 index 00000000..268369ce --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/entity/PlaceReview.java @@ -0,0 +1,66 @@ +package com.curateme.claco.review.domain.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.global.entity.BaseEntity; + +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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.10.28 + * @author devkeon(devkeon123@gmail.com) + * @details : TicketReview & PlaceCategory 다대다 해결 엔티티 + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.28 이 건 최초 생성 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE place_review SET active_status = 'DELETED' WHERE place_review_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class PlaceReview extends BaseEntity { + + @Id + @Column(name = "place_review_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 다대일 단방향 매핑(일대다-다대일 해결 테이블) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_category_id") + private PlaceCategory placeCategory; + + // 다대일 양방향 매핑(일대다-다대일 해결 테이블) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_review_id") + private TicketReview ticketReview; + + // TickerReview 연관관계 편의 메서드 + public void updateTicketReview(TicketReview ticketReview) { + if (this.ticketReview != ticketReview) { + this.ticketReview = ticketReview; + } + if (!ticketReview.getPlaceReviews().contains(this)) { + ticketReview.addPlaceReview(this); + } + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/entity/ReviewImage.java b/src/main/java/com/curateme/claco/review/domain/entity/ReviewImage.java new file mode 100644 index 00000000..ceca5826 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/entity/ReviewImage.java @@ -0,0 +1,62 @@ +package com.curateme.claco.review.domain.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.global.entity.BaseEntity; + +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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.10.28 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.28 이 건 최초 생성 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE review_image SET active_status = 'DELETED' WHERE review_image_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class ReviewImage extends BaseEntity { + + @Id + @Column(name = "review_image_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + // 티켓 리뷰 다대일 양방향 매핑 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_review_id") + private TicketReview ticketReview; + // 리뷰 이미지 S3 url + private String imageUrl; + + + // TicketReview 연관관계 편의 메서드 + public void updateTicketReview(TicketReview ticketReview) { + if (this.ticketReview != ticketReview) { + this.ticketReview = ticketReview; + } + if (!ticketReview.getReviewImages().contains(this)) { + ticketReview.addReviewImage(this); + } + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/entity/ReviewTag.java b/src/main/java/com/curateme/claco/review/domain/entity/ReviewTag.java new file mode 100644 index 00000000..d78a7965 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/entity/ReviewTag.java @@ -0,0 +1,62 @@ +package com.curateme.claco.review.domain.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.global.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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; + +/** + * @author : 이 건 + * @date : 2024.11.03 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.03 이 건 최초 생성 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE review_tag SET active_status = 'DELETED' WHERE review_tag_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class ReviewTag extends BaseEntity { + + @Id @Column(name = "review_tag_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 다대일 양방향 매핑(일대다-다대일 해결 테이블) + @ManyToOne + @JoinColumn(name = "ticket_review_id") + private TicketReview ticketReview; + + // 다대일 단방향 매핑(일대다-다대일 해결 테이블) + @ManyToOne + @JoinColumn(name = "tag_category_id") + private TagCategory tagCategory; + + // TicketReview 연관관계 편의 메서드 + public void updateTicketReview(TicketReview ticketReview) { + if (this.ticketReview != ticketReview) { + this.ticketReview = ticketReview; + } + if (!ticketReview.getReviewTags().contains(this)) { + ticketReview.addReviewTag(this); + } + } +} diff --git a/src/main/java/com/curateme/claco/review/domain/entity/TagCategory.java b/src/main/java/com/curateme/claco/review/domain/entity/TagCategory.java new file mode 100644 index 00000000..c7191f91 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/entity/TagCategory.java @@ -0,0 +1,46 @@ +package com.curateme.claco.review.domain.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.global.entity.BaseEntity; + +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; + +/** + * @author : 이 건 + * @date : 2024.11.03 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.03 이 건 최초 생성 + * 2024.11.05 이 건 이미지 url 추가 + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE tag_category SET active_status = 'DELETED' WHERE tag_category_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class TagCategory extends BaseEntity { + + @Id @Column(name = "tag_category_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + // 태그명 + private String name; + // 이미지 Url + private String imageUrl; + +} diff --git a/src/main/java/com/curateme/claco/review/domain/entity/TicketReview.java b/src/main/java/com/curateme/claco/review/domain/entity/TicketReview.java new file mode 100644 index 00000000..153c3911 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/entity/TicketReview.java @@ -0,0 +1,180 @@ +package com.curateme.claco.review.domain.entity; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; + +import org.hibernate.annotations.BatchSize; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.global.entity.BaseEntity; +import com.curateme.claco.member.domain.entity.Member; + +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.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.10.28 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.28 이 건 최초 생성 + * 2024.11.03 이 건 ReviewTag, Concert 매핑 추가 + * 2024.11.04 이 건 관람 좌석 NotNull 조건 해제(요구사항) + */ +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE ticket_review SET active_status = 'DELETED' WHERE ticket_review_id = ?") +@SQLRestriction("active_status <> 'DELETED'") +public class TicketReview extends BaseEntity { + + @Id @Column(name = "ticket_review_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 리뷰 장소평 일대다 양방향 매핑 + @Builder.Default + @BatchSize(size = 5) + @OneToMany(mappedBy = "ticketReview", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Set placeReviews = new HashSet<>(); + + // 리뷰 이미지 일대다 양방향 매핑 + @Builder.Default + @BatchSize(size = 3) + @OneToMany(mappedBy = "ticketReview", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Set reviewImages = new HashSet<>(); + + @Builder.Default + @BatchSize(size = 5) + @OneToMany(mappedBy = "ticketReview", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Set reviewTags = new HashSet<>(); + + @ManyToOne + @JoinColumn(name = "claco_book_id") + private ClacoBook clacoBook; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "concert_id") + private Concert concert; + + // 다대일 양방향 매핑 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + // 관람 회차 + @NotNull + private String watchRound; + // 관람 일자 + @NotNull + private LocalDate watchDate; + // 관람 좌석 + private String watchSit; + // 별점 + @NotNull + @Column(precision = 2, scale = 1) + private BigDecimal starRate; + // 티켓 이미지 (클라코 생성) + private String ticketImage; + // 리뷰 내용 + @NotNull + @Column(length = 500) + private String content; + @NotNull + private String casting; + + // Member 연관관계 편의 메서드 + public void updateMember(Member member) { + if (this.member != member) { + this.member = member; + } + if (!member.getTicketReviews().contains(this)) { + member.addTicketReview(this); + } + } + + // ClacoBook 연관관계 편의 메서드 + public void updateClacoBook(ClacoBook clacoBook) { + if (this.clacoBook != clacoBook) { + this.clacoBook = clacoBook; + } + if (!clacoBook.getTicketReviews().contains(this)) { + clacoBook.addTicketReview(this); + } + } + + // PlaceReview 연관관계 편의 메서드 + public void addPlaceReview(PlaceReview placeReview) { + if (!this.placeReviews.contains(placeReview)) { + this.placeReviews.add(placeReview); + } + if (placeReview.getTicketReview() != this) { + placeReview.updateTicketReview(this); + } + } + + // ReviewImage 연관관계 편의 메서드 + public void addReviewImage(ReviewImage reviewImage) { + if (!this.reviewImages.contains(reviewImage)) { + this.reviewImages.add(reviewImage); + } + if (reviewImage.getTicketReview() != this) { + reviewImage.updateTicketReview(this); + } + } + + // ReviewTag 연관관계 편의 메서드 + public void addReviewTag(ReviewTag reviewTag) { + if (!this.reviewTags.contains(reviewTag)) { + this.reviewTags.add(reviewTag); + } + if (reviewTag.getTicketReview() != this) { + reviewTag.updateTicketReview(this); + } + } + + public void updateTicketImage(String ticketImage) { + this.ticketImage = ticketImage; + } + + public void updateWatchSit(String watchSit) { + if (watchSit != null) { + this.watchSit = watchSit; + } + } + + public void updateStarRate(BigDecimal starRate) { + if (starRate != null) { + this.starRate = starRate; + } + } + + public void updateContent(String content) { + if (content != null) { + this.content = content; + } + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/vo/ImageUrlVO.java b/src/main/java/com/curateme/claco/review/domain/vo/ImageUrlVO.java new file mode 100644 index 00000000..c6a7d035 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/vo/ImageUrlVO.java @@ -0,0 +1,40 @@ +package com.curateme.claco.review.domain.vo; + +import com.curateme.claco.review.domain.entity.ReviewImage; +import com.curateme.claco.review.domain.entity.TicketReview; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.03 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.03 이 건 최초 생성 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ImageUrlVO { + + // 이미지 주소 + @Schema(description = "이미지 Url", example = "https://claco.com") + private String imageUrl; + + public static ImageUrlVO fromReviewImage(ReviewImage image) { + return new ImageUrlVO(image.getImageUrl()); + } + + public static ImageUrlVO fromTicketImage(TicketReview ticketReview) { + return new ImageUrlVO(ticketReview.getTicketImage()); + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/vo/PlaceCategoryVO.java b/src/main/java/com/curateme/claco/review/domain/vo/PlaceCategoryVO.java new file mode 100644 index 00000000..2425a157 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/vo/PlaceCategoryVO.java @@ -0,0 +1,39 @@ +package com.curateme.claco.review.domain.vo; + +import com.curateme.claco.review.domain.entity.PlaceCategory; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.03 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.03 이 건 최초 생성 + * 2024.11.05 이 건 Swagger 적용 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PlaceCategoryVO { + + // id + @Schema(description = "장소평 카테고리 id", example = "1") + private Long placeCategoryId; + // 장소평 태그 이름 + @Schema(description = "장소평 카테고리 이름", example = "깨끗해요.") + private String categoryName; + + public static PlaceCategoryVO fromEntity(PlaceCategory placeCategory) { + return new PlaceCategoryVO(placeCategory.getId(), placeCategory.getName()); + } + +} diff --git a/src/main/java/com/curateme/claco/review/domain/vo/TagCategoryVO.java b/src/main/java/com/curateme/claco/review/domain/vo/TagCategoryVO.java new file mode 100644 index 00000000..c4e67a2c --- /dev/null +++ b/src/main/java/com/curateme/claco/review/domain/vo/TagCategoryVO.java @@ -0,0 +1,41 @@ +package com.curateme.claco.review.domain.vo; + +import com.curateme.claco.review.domain.entity.TagCategory; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author : 이 건 + * @date : 2024.11.03 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.03 이 건 최초 생성 + * 2024.11.05 이 건 Swagger 적용 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TagCategoryVO { + + // id + @Schema(description = "공연 성격 Id", example = "1") + private Long tagCategoryId; + // 태그 이름 + @Schema(description = "공연 성격 이름", example = "웅장한") + private String tagName; + @Schema(description = "공연 성격 아이콘 이미지", example = "https://claco.com") + private String iconUrl; + + public static TagCategoryVO fromEntity(TagCategory tagCategory) { + return new TagCategoryVO(tagCategory.getId(), tagCategory.getName(), tagCategory.getImageUrl()); + } + +} diff --git a/src/main/java/com/curateme/claco/review/repository/PlaceCategoryRepository.java b/src/main/java/com/curateme/claco/review/repository/PlaceCategoryRepository.java new file mode 100644 index 00000000..e7f99c41 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/repository/PlaceCategoryRepository.java @@ -0,0 +1,17 @@ +package com.curateme.claco.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.curateme.claco.review.domain.entity.PlaceCategory; + +/** + * @author : 이 건 + * @date : 2024.10.29 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.29 이 건 최초 생성 + */ +public interface PlaceCategoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/curateme/claco/review/repository/PlaceReviewRepository.java b/src/main/java/com/curateme/claco/review/repository/PlaceReviewRepository.java new file mode 100644 index 00000000..e38c6506 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/repository/PlaceReviewRepository.java @@ -0,0 +1,17 @@ +package com.curateme.claco.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.curateme.claco.review.domain.entity.PlaceReview; + +/** + * @author : 이 건 + * @date : 2024.10.29 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.29 이 건 최초 생성 + */ +public interface PlaceReviewRepository extends JpaRepository { +} diff --git a/src/main/java/com/curateme/claco/review/repository/ReviewImageRepository.java b/src/main/java/com/curateme/claco/review/repository/ReviewImageRepository.java new file mode 100644 index 00000000..84f81c4d --- /dev/null +++ b/src/main/java/com/curateme/claco/review/repository/ReviewImageRepository.java @@ -0,0 +1,17 @@ +package com.curateme.claco.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.curateme.claco.review.domain.entity.ReviewImage; + +/** + * @author : 이 건 + * @date : 2024.10.29 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.29 이 건 최초 생성 + */ +public interface ReviewImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/curateme/claco/review/repository/ReviewTagRepository.java b/src/main/java/com/curateme/claco/review/repository/ReviewTagRepository.java new file mode 100644 index 00000000..51e024b0 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/repository/ReviewTagRepository.java @@ -0,0 +1,8 @@ +package com.curateme.claco.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.curateme.claco.review.domain.entity.ReviewTag; + +public interface ReviewTagRepository extends JpaRepository { +} diff --git a/src/main/java/com/curateme/claco/review/repository/TagCategoryRepository.java b/src/main/java/com/curateme/claco/review/repository/TagCategoryRepository.java new file mode 100644 index 00000000..51c1fc1f --- /dev/null +++ b/src/main/java/com/curateme/claco/review/repository/TagCategoryRepository.java @@ -0,0 +1,8 @@ +package com.curateme.claco.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.curateme.claco.review.domain.entity.TagCategory; + +public interface TagCategoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/curateme/claco/review/repository/TicketReviewRepository.java b/src/main/java/com/curateme/claco/review/repository/TicketReviewRepository.java new file mode 100644 index 00000000..cf113b8f --- /dev/null +++ b/src/main/java/com/curateme/claco/review/repository/TicketReviewRepository.java @@ -0,0 +1,90 @@ +package com.curateme.claco.review.repository; + +import com.curateme.claco.review.domain.dto.response.TicketReviewSummaryResponse; +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 com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.review.domain.entity.TicketReview; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * @author : 이 건 + * @date : 2024.10.29 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.29 이 건 최초 생성 + * 2024.11.04 이 건 조회 및 카운팅 메서드 추가 + */ +public interface TicketReviewRepository extends JpaRepository { + + /** + * 콘서트 정보 제외 호출 + * @param id : 조회하고자 하는 TicketReview id + * @return : TickerReview optional + */ + @EntityGraph(attributePaths = {"placeReviews", "placeReviews.placeCategory", "reviewImages", "reviewTags", + "reviewTags.tagCategory", "member"}) + Optional findTicketReviewById(Long id); + + /** + * 모든 정보 조인 후 호출 + * @param id : 조회하고자 하는 티켓리뷰 id + * @return : TickerReview optional + */ + @EntityGraph(attributePaths = {"placeReviews", "placeReviews.placeCategory", "reviewImages", "reviewTags", + "reviewTags.tagCategory", "member", "concert"}) + Optional findTicketReviewByIdIs(Long id); + + /** + * 별점 높은 순 정렬, 페이징 조회 + * @param concert : 조회하고자 하는 공연 엔티티 + * @param pageable : 페이징 + * @return : TicketReview 객체 리스트 (페이징) + */ + @EntityGraph(attributePaths = {"placeReviews", "placeReviews.placeCategory", "reviewImages", "member"}) + Page findAllByConcertOrderByStarRateDescIdDesc(Concert concert, Pageable pageable); + + /** + * 별점 낮은 순 정렬, 페이징 조회 + * @param concert : 조회하고자 하는 공연 엔티티 + * @param pageable : 페이징 + * @return : TicketReview 객체 리스트 (페이징) + */ + @EntityGraph(attributePaths = {"placeReviews", "placeReviews.placeCategory", "reviewImages", "member"}) + Page findAllByConcertOrderByStarRateAscIdDesc(Concert concert, Pageable pageable); + + /** + * 최신순 정렬, 페이징 조회 + * @param concert : 조회하고자 하는 공연 엔티티 + * @param pageable : 페이징 + * @return : TicketReview 객체 리스트 (페이징) + */ + @EntityGraph(attributePaths = {"placeReviews", "placeReviews.placeCategory", "reviewImages", "member"}) + Page findAllByConcertOrderByIdDesc(Concert concert, Pageable pageable); + + /** + * 공연의 총 리뷰 개수 조회 메서드 + * @param concert : 조회하고자 하는 공연 + * @return : 총 리뷰 개수 + */ + Integer countTicketReviewByConcert(Concert concert); + + List findByClacoBook(ClacoBook clacoBook); + + @Query("SELECT new com.curateme.claco.review.domain.dto.response.TicketReviewSummaryResponse(tr.member.nickname, tr.concert.prfnm , tr.concert.id, tr.createdAt, tr.content) " + + "FROM TicketReview tr WHERE tr.id = :id") + TicketReviewSummaryResponse findSummaryById(@Param("id") Long id); + + @Query("SELECT tr.id FROM TicketReview tr WHERE tr.concert.id = :concertId") + List findByConcertId(@Param("concertId") Long concertId); +} diff --git a/src/main/java/com/curateme/claco/review/service/PlaceCategoryService.java b/src/main/java/com/curateme/claco/review/service/PlaceCategoryService.java new file mode 100644 index 00000000..f507db3a --- /dev/null +++ b/src/main/java/com/curateme/claco/review/service/PlaceCategoryService.java @@ -0,0 +1,24 @@ +package com.curateme.claco.review.service; + +import java.util.List; + +import com.curateme.claco.review.domain.vo.PlaceCategoryVO; + +/** + * @author : 이 건 + * @date : 2024.11.03 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.03 이 건 최초 생성 + */ +public interface PlaceCategoryService { + + /** + * 장소평 카테고리 리스트 읽어오기 + * @return : 장소평 카테고리 이름 및 id 반환 + */ + List readPlaceCategoryList(); + +} diff --git a/src/main/java/com/curateme/claco/review/service/PlaceCategoryServiceImpl.java b/src/main/java/com/curateme/claco/review/service/PlaceCategoryServiceImpl.java new file mode 100644 index 00000000..f04fb28f --- /dev/null +++ b/src/main/java/com/curateme/claco/review/service/PlaceCategoryServiceImpl.java @@ -0,0 +1,27 @@ +package com.curateme.claco.review.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.curateme.claco.review.domain.vo.PlaceCategoryVO; +import com.curateme.claco.review.repository.PlaceCategoryRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class PlaceCategoryServiceImpl implements PlaceCategoryService{ + + private final PlaceCategoryRepository placeCategoryRepository; + + @Override + public List readPlaceCategoryList() { + + return placeCategoryRepository.findAll().stream() + .map(PlaceCategoryVO::fromEntity) + .toList(); + } +} diff --git a/src/main/java/com/curateme/claco/review/service/TagCategoryService.java b/src/main/java/com/curateme/claco/review/service/TagCategoryService.java new file mode 100644 index 00000000..036ca5e3 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/service/TagCategoryService.java @@ -0,0 +1,24 @@ +package com.curateme.claco.review.service; + +import java.util.List; + +import com.curateme.claco.review.domain.vo.TagCategoryVO; + +/** + * @author : 이 건 + * @date : 2024.11.03 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.03 이 건 최초 생성 + */ +public interface TagCategoryService { + + /** + * 감상평 카테고리 리스트 읽어오기 + * @return : 감상평 카테고리 이름 및 id 반환 + */ + List readTagCategoryList(); + +} diff --git a/src/main/java/com/curateme/claco/review/service/TagCategoryServiceImpl.java b/src/main/java/com/curateme/claco/review/service/TagCategoryServiceImpl.java new file mode 100644 index 00000000..83486bab --- /dev/null +++ b/src/main/java/com/curateme/claco/review/service/TagCategoryServiceImpl.java @@ -0,0 +1,28 @@ +package com.curateme.claco.review.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.curateme.claco.review.domain.vo.TagCategoryVO; +import com.curateme.claco.review.repository.TagCategoryRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class TagCategoryServiceImpl implements TagCategoryService { + + private final TagCategoryRepository tagCategoryRepository; + + @Override + public List readTagCategoryList() { + return tagCategoryRepository.findAll().stream() + .map(TagCategoryVO::fromEntity) + .toList(); + } +} diff --git a/src/main/java/com/curateme/claco/review/service/TicketReviewServiceImpl.java b/src/main/java/com/curateme/claco/review/service/TicketReviewServiceImpl.java new file mode 100644 index 00000000..0bdfe983 --- /dev/null +++ b/src/main/java/com/curateme/claco/review/service/TicketReviewServiceImpl.java @@ -0,0 +1,343 @@ +package com.curateme.claco.review.service; + +import java.io.IOException; +import java.util.List; +import java.util.stream.IntStream; + +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 org.springframework.web.multipart.MultipartFile; + +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.clacobook.repository.ClacoBookRepository; +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.concert.repository.ConcertRepository; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.global.util.S3Util; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; +import com.curateme.claco.review.domain.dto.request.OrderBy; +import com.curateme.claco.review.domain.dto.request.TicketReviewCreateRequest; +import com.curateme.claco.review.domain.dto.TicketReviewUpdateDto; +import com.curateme.claco.review.domain.dto.response.ReviewInfoResponse; +import com.curateme.claco.review.domain.dto.response.ReviewListResponse; +import com.curateme.claco.review.domain.dto.response.TicketInfoResponse; +import com.curateme.claco.review.domain.dto.response.TicketListResponse; +import com.curateme.claco.review.domain.dto.response.TicketReviewInfoResponse; +import com.curateme.claco.review.domain.entity.PlaceReview; +import com.curateme.claco.review.domain.entity.ReviewImage; +import com.curateme.claco.review.domain.entity.ReviewTag; +import com.curateme.claco.review.domain.entity.TicketReview; +import com.curateme.claco.review.domain.vo.ImageUrlVO; +import com.curateme.claco.review.repository.PlaceCategoryRepository; +import com.curateme.claco.review.repository.PlaceReviewRepository; +import com.curateme.claco.review.repository.ReviewImageRepository; +import com.curateme.claco.review.repository.ReviewTagRepository; +import com.curateme.claco.review.repository.TagCategoryRepository; +import com.curateme.claco.review.repository.TicketReviewRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @author : 이 건 + * @date : 2024.11.04 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.11.04 이 건 최초 생성 + */ +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class TicketReviewServiceImpl { + + private final TicketReviewRepository ticketReviewRepository; + private final SecurityContextUtil securityContextUtil; + private final MemberRepository memberRepository; + private final ReviewImageRepository reviewImageRepository; + private final PlaceCategoryRepository placeCategoryRepository; + private final PlaceReviewRepository placeReviewRepository; + private final TagCategoryRepository tagCategoryRepository; + private final ReviewTagRepository reviewTagRepository; + private final ConcertRepository concertRepository; + private final ClacoBookRepository clacoBookRepository; + private final S3Util s3Util; + + public TicketReviewInfoResponse createTicketReview(TicketReviewCreateRequest request, + MultipartFile[] multipartFile) throws IOException { + // 이미지 개수 검사 + if (multipartFile.length > 3) { + throw new BusinessException(ApiStatus.IMAGE_TOO_MANY); + } + + // 현재 접근 사용자 조회 + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + // claco book 정보 찾기 + ClacoBook clacoBook; + if (request.getClacoBookId() != null) { + clacoBook = clacoBookRepository.findByIdIs(request.getClacoBookId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.CLACO_BOOK_NOT_FOUND)); + } else { + clacoBook = clacoBookRepository.save(ClacoBook.builder() + .member(member) + .title(member.getNickname() + "님의 첫번째 이야기") + .color("#88888") + .build()); + } + + if (clacoBook.getTicketReviews().size() >= 20) { + throw new BusinessException(ApiStatus.EXCEED_SIZE_LIMIT); + } + + // TicketReview 조립 + TicketReview ticketReview = TicketReview.builder() + .concert(concertRepository.findById(request.getConcertId()) + .stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.CONCERT_NOT_FOUND))) + .member(member) + .clacoBook(clacoBook) + .starRate(request.getStarRate()) + .content(request.getContent()) + .watchDate(request.getWatchDate()) + .watchRound(request.getWatchRound()) + .watchSit(request.getWatchSit()) + .casting(request.getCasting()) + .build(); + + TicketReview savedTicketReview = ticketReviewRepository.save(ticketReview); + + // PlaceCategory 매핑 + request.getPlaceReviewIds().stream() + .map( + placeCategoryVO -> placeCategoryRepository.findById(placeCategoryVO.getPlaceCategoryId()) + .stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.PLACE_CATEGORY_NOT_FOUND)) + ) + .toList() + .forEach( + placeCategory -> { + PlaceReview placeReview = PlaceReview.builder() + .ticketReview(savedTicketReview) + .placeCategory(placeCategory) + .build(); + PlaceReview savedPlaceReview = placeReviewRepository.save(placeReview); + savedTicketReview.addPlaceReview(savedPlaceReview); + } + ); + + // TagCategory 매핑 + request.getTagCategoryIds().stream() + .map(tagCategoryVO -> tagCategoryRepository.findById(tagCategoryVO.getTagCategoryId()) + .stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.CONCERT_TAG_NOT_FOUND))) + .toList() + .forEach( + tagCategory -> { + ReviewTag reviewTag = ReviewTag.builder() + .ticketReview(savedTicketReview) + .tagCategory(tagCategory) + .build(); + ReviewTag savedReviewTage = reviewTagRepository.save(reviewTag); + savedTicketReview.addReviewTag(savedReviewTage); + } + ); + + // 리뷰 이미지 생성 + String baseUrl = "review-image/" + savedTicketReview.getId() + "/"; + IntStream.range(0, multipartFile.length).forEach(idx -> { + MultipartFile file = multipartFile[idx]; + String s3Url; + try { + s3Url = s3Util.uploadImage(file, baseUrl + (idx + 1)); + } catch (IOException e) { + throw new BusinessException(ApiStatus.S3_UPLOAD_ERROR); + } + ReviewImage reviewImage = ReviewImage.builder() + .ticketReview(savedTicketReview) + .imageUrl(s3Url) + .build(); + ReviewImage savedReviewImage = reviewImageRepository.save(reviewImage); + savedTicketReview.addReviewImage(savedReviewImage); + }); + + TicketReviewInfoResponse response = TicketReviewInfoResponse.fromTicketReview(savedTicketReview); + response.updateClacoBookId(clacoBook.getId()); + return response; + } + + public ImageUrlVO addNewTicket(Long ticketReviewId, MultipartFile multipartFile) throws IOException { + // 현재 접근 멤버 조회 + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + TicketReview ticketReview = ticketReviewRepository.findById(ticketReviewId).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.TICKET_REVIEW_NOT_FOUND)); + + // 소유주 체크 + if (ticketReview.getMember() != member) { + throw new BusinessException(ApiStatus.MEMBER_NOT_OWNER); + } + + // S3 업로드 + String s3Url = s3Util.uploadImage(multipartFile, "ticket-image/" + ticketReview.getId()); + ticketReview.updateTicketImage(s3Url); + return ImageUrlVO.fromTicketImage(ticketReview); + } + + public ReviewInfoResponse readReview(Long reviewId) { + TicketReview ticketReview = ticketReviewRepository.findTicketReviewById(reviewId).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.TICKET_REVIEW_NOT_FOUND)); + + return ReviewInfoResponse.fromEntityToDetailReview(ticketReview); + } + + public TicketReviewInfoResponse readTicketReview(Long ticketReviewId) { + // 현재 접근 멤버 조회 + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + TicketReview ticketReview = ticketReviewRepository.findTicketReviewByIdIs(ticketReviewId).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.TICKET_REVIEW_NOT_FOUND)); + + TicketReviewInfoResponse response = TicketReviewInfoResponse.fromTicketReview(ticketReview); + + // 소유주 여부 체크 + if (member != ticketReview.getMember()) { + response.setEditor(false); + } + + return response; + } + + public ReviewListResponse readReviewOfConcert(Long concertId, Integer page, Integer size, OrderBy orderBy) { + // 공연 정보 조회 + Concert concert = concertRepository.findById(concertId).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.CONCERT_NOT_FOUND)); + + Page result; + + Pageable pageable = PageRequest.of(page, size); + + // TODO: 동적 쿼리 고려 + if (orderBy.equals(OrderBy.HIGH_RATE)) { + result = ticketReviewRepository.findAllByConcertOrderByStarRateDescIdDesc(concert, pageable); + } else if (orderBy.equals(OrderBy.LOW_RATE)) { + result = ticketReviewRepository.findAllByConcertOrderByStarRateAscIdDesc(concert, pageable); + } else { + result = ticketReviewRepository.findAllByConcertOrderByIdDesc(concert, pageable); + } + + List data = result.get().map(ReviewInfoResponse::fromEntityToSimpleReview).toList(); + + return ReviewListResponse.builder() + .reviewList(data) + .totalPage(result.getTotalPages()) + .currentPage(result.getNumber() + 1) + .size(result.getSize()) + .build(); + } + + public TicketListResponse readTicketOfClacoBook(Long clacoBookId) { + ClacoBook clacoBook = clacoBookRepository.findById(clacoBookId).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.CLACO_BOOK_NOT_FOUND)); + + List infoResponseList = ticketReviewRepository.findByClacoBook(clacoBook).stream() + .map(TicketInfoResponse::fromEntity) + .toList(); + + return new TicketListResponse(infoResponseList); + } + + public Integer countReview(Long concertId) { + // 공연 조회 + Concert concert = concertRepository.findById(concertId).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.CONCERT_NOT_FOUND)); + + return ticketReviewRepository.countTicketReviewByConcert(concert); + } + + public TicketReviewUpdateDto editTicketReview(TicketReviewUpdateDto request) { + // 접근 사용자 조회 + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + TicketReview ticketReview = ticketReviewRepository.findById(request.getTicketReviewId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.TICKET_REVIEW_NOT_FOUND)); + + // 소유주 조회 + if (ticketReview.getMember() != member) { + throw new BusinessException(ApiStatus.MEMBER_NOT_OWNER); + } + + ticketReview.updateWatchSit(request.getWatchSit()); + ticketReview.updateStarRate(request.getStarRate()); + ticketReview.updateContent(request.getContent()); + + return TicketReviewUpdateDto.fromEntity(ticketReview); + } + + public void deleteTicket(Long ticketReviewId) { + // 접근 사용자 조회 + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + TicketReview ticketReview = ticketReviewRepository.findById(ticketReviewId).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.TICKET_REVIEW_NOT_FOUND)); + + // 소유주 조회 + if (ticketReview.getMember() != member) { + throw new BusinessException(ApiStatus.MEMBER_NOT_OWNER); + } + + ticketReviewRepository.delete(ticketReview); + + } + + public void moveTicketReview(Long ticketReviewId, Long clacoBookId) { + Member member = memberRepository.findById(securityContextUtil.getContextMemberInfo().getMemberId()).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.MEMBER_NOT_FOUND)); + + TicketReview ticketReview = ticketReviewRepository.findById(ticketReviewId).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.TICKET_REVIEW_NOT_FOUND)); + + // 소유주 조회 + if (ticketReview.getMember() != member) { + throw new BusinessException(ApiStatus.MEMBER_NOT_OWNER); + } + + ClacoBook clacoBook = clacoBookRepository.findById(clacoBookId).stream() + .findAny() + .orElseThrow(() -> new BusinessException(ApiStatus.CLACO_BOOK_NOT_FOUND)); + + ticketReview.updateClacoBook(clacoBook); + } +} diff --git a/src/main/resources/application-aws.yml b/src/main/resources/application-aws.yml new file mode 100644 index 00000000..b0b98c9a --- /dev/null +++ b/src/main/resources/application-aws.yml @@ -0,0 +1,13 @@ +cloud: + aws: + s3: + bucket-name: ${AWS_BUCKET_NAME} + credentials: + accessKey: ${AWS_ACCESS_KEY} + secretKey: ${AWS_SECRET_KEY} + region: + static: ${AWS_REGION} + stack: + auto: false + ai: + url: ${FLASK_SERVER} \ No newline at end of file diff --git a/src/main/resources/application-oauth.yml b/src/main/resources/application-oauth.yml new file mode 100644 index 00000000..7508c45b --- /dev/null +++ b/src/main/resources/application-oauth.yml @@ -0,0 +1,27 @@ +spring: + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: account_email, profile_image + client-name: Kakao + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id +jwt: + token: + secret-key: ${JWT_SECRET_KEY} + expire: + refresh: ${JWT_REFRESH_EXPIRE} + access: ${JWT_ACCESS_EXPIRE} + cookie: + expire: ${JWT_REFRESH_EXPIRE} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..f838e34c --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,16 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: update + generate-ddl: false +front: + url: ${FRONT_URL} +backend: + domain: ${BACKEND_DOMAIN} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..230cea74 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,32 @@ +spring: + application: + name: claco + profiles: + active: + - prod + group: + local: + - local + prod: + - prod + include: + - oauth + - aws + servlet: + multipart: + resolve-lazily: true + max-file-size: 15MB + max-request-size: 15MB +management: + endpoints: + web: + exposure: + include: prometheus + metrics: + tags: + application: main-server + prometheus: + metrics: + export: + enabled: true + step: 1m diff --git a/src/test/java/com/curateme/claco/ClacoApplicationTests.java b/src/test/java/com/curateme/claco/ClacoApplicationTests.java new file mode 100644 index 00000000..16d303f9 --- /dev/null +++ b/src/test/java/com/curateme/claco/ClacoApplicationTests.java @@ -0,0 +1,13 @@ +package com.curateme.claco; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ClacoApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/curateme/claco/authentication/util/JwtTokenUtilImplTest.java b/src/test/java/com/curateme/claco/authentication/util/JwtTokenUtilImplTest.java new file mode 100644 index 00000000..d6b7d2a1 --- /dev/null +++ b/src/test/java/com/curateme/claco/authentication/util/JwtTokenUtilImplTest.java @@ -0,0 +1,268 @@ +package com.curateme.claco.authentication.util; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.util.Collections; +import java.util.Date; +import java.util.Optional; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; +import com.curateme.claco.member.repository.MemberRepository; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +/** + * @packageName : com.curateme.claco.authentication.util + * @fileName : JwtTokenUtilImplTest.java + * @author : 이 건 + * @date : 2024.10.17 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.17 이 건 최초 생성 + */ +@Slf4j +class JwtTokenUtilImplTest { + + private JwtTokenUtil jwtTokenUtil; + private final String secretKey = "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttest"; + private final MemberRepository memberRepository = mock(MemberRepository.class); + private final Long accessExpiration = 1800000L; + private final Long refreshExpiration = 604800000L; + private final SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); + + @BeforeEach + public void beforeEach() { + jwtTokenUtil = new JwtTokenUtilImpl(secretKey, accessExpiration, refreshExpiration, memberRepository); + } + + @Test + @DisplayName("엑세스 토큰 생성") + void generateAccessToken() { + + // Given + Authentication authentication = mock(Authentication.class); + JwtMemberDetail jwtMemberDetail = mock(JwtMemberDetail.class); + Long memberId = 1L; + + GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_MEMBER"); + + doReturn(Collections.singleton(authority)).when(authentication).getAuthorities(); + doReturn(jwtMemberDetail).when(authentication).getPrincipal(); + doReturn(memberId).when(jwtMemberDetail).getMemberId(); + + // When + String accessToken = jwtTokenUtil.generateAccessToken(authentication); + + //Then + log.info("accessToken={}", accessToken); + + verify(authentication, times(1)).getAuthorities(); + verify(authentication, times(1)).getPrincipal(); + verify(jwtMemberDetail, times(1)).getMemberId(); + + // accessToken 형식 검증 + assertThat(accessToken).isNotNull(); + assertThat(accessToken).isNotEmpty(); + assertThat(accessToken.split("\\.")).hasSize(3); + + // accessToken 내용 검증 + Claims payload = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(accessToken.replace("Bearer ", "")) + .getPayload(); + + assertThat(payload.get("auth")).isEqualTo(authority.getAuthority()); + assertThat(Long.valueOf(payload.get("id").toString())).isEqualTo(memberId); + + } + + @Test + @DisplayName("리프레시 토큰 생성") + void generateRefreshToken() { + + // When + String refreshToken = jwtTokenUtil.generateRefreshToken(); + + // Then + log.info("refreshToken={}", refreshToken); + + // refreshToken 형식 검증 + assertThat(refreshToken).isNotNull(); + assertThat(refreshToken).isNotEmpty(); + assertThat(refreshToken.split("\\.")).hasSize(3); + + // refreshToken 내용 검증 + Claims payload = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(refreshToken) + .getPayload(); + + assertThat(payload.get("sub").toString()).isEqualTo("refreshToken"); + + } + + @Test + @DisplayName("엑세스 토큰으로부터 인증(Authentication) 정보 얻기") + void getAuthentication() { + + // Given + Authentication authentication = mock(Authentication.class); + Long memberId = 1L; + Member member = Member.builder() + .id(memberId) + .email("test") + .nickname("test") + .socialId(memberId) + .role(Role.MEMBER) + .build(); + + GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_MEMBER"); + + String testAccessToken = Jwts.builder() + .subject(authentication.getName()) + .claim("auth", authority) + .claim("id", memberId) + .signWith(key) + .expiration(Date.from(Instant.now().plusMillis(accessExpiration))) + .compact(); + + doReturn(Optional.of(member)).when(memberRepository).findById(memberId); + + // When + Authentication checkAuthentication = jwtTokenUtil.getAuthentication(testAccessToken); + + // Then + log.info("authority={}", checkAuthentication.getAuthorities()); + + verify(memberRepository, times(1)).findById(memberId); + JwtMemberDetail checkJwtMemberDetail = (JwtMemberDetail)checkAuthentication.getPrincipal(); + + assertThat(checkJwtMemberDetail.getMemberId()).isEqualTo(memberId); + assertThat(checkJwtMemberDetail.getUsername()).isEqualTo("test"); + + } + + @Test + @DisplayName("인증(Authentication) 정보 생성") + void createAuthentication() { + + // Given + GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_MEMBER"); + Long memberId = 1L; + Member member = Member.builder() + .id(memberId) + .email("test") + .nickname("test") + .socialId(memberId) + .role(Role.MEMBER) + .build(); + + // When + Authentication authentication = jwtTokenUtil.createAuthentication(member); + + // Then + JwtMemberDetail jwtMemberDetail = (JwtMemberDetail)authentication.getPrincipal(); + + authentication.getAuthorities().forEach((grantedAuthority -> { + assertThat(grantedAuthority.getAuthority()).isEqualTo(authority.getAuthority()); + })); + + assertThat(jwtMemberDetail.getUsername()).isEqualTo(member.getEmail()); + assertThat(jwtMemberDetail.getMemberId()).isEqualTo(member.getId()); + + } + + @Test + @DisplayName("Request Header 로부터 엑세스 토큰 추출") + void extractAccessToken() { + + // Given + HttpServletRequest request = mock(HttpServletRequest.class); + + String testAccessToken = "Bearer " + Jwts.builder() + .signWith(key) + .expiration(Date.from(Instant.now().plusMillis(accessExpiration))) + .compact(); + + doReturn(testAccessToken).when(request).getHeader("Authorization"); + + // When + Optional assertToken = jwtTokenUtil.extractAccessToken(request); + + // Then + verify(request, times(1)).getHeader("Authorization"); + + assertThat(assertToken.isEmpty()).isFalse(); + assertThat(assertToken.get()).isEqualTo(testAccessToken.replace("Bearer ", "")); + + } + + @Test + @DisplayName("쿠키로부터 리프레시 토큰 추출") + void extractRefreshToken() { + + // Given + HttpServletRequest request = mock(HttpServletRequest.class); + + String refreshToken = Jwts.builder() + .subject("refreshToken") + .expiration(Date.from(Instant.now().plusMillis(refreshExpiration))) + .signWith(key) + .compact(); + Cookie[] cookie = {new Cookie("refreshToken", refreshToken)}; + + doReturn(cookie).when(request).getCookies(); + + // When + Optional assertToken = jwtTokenUtil.extractRefreshToken(request); + + // Then + verify(request, times(1)).getCookies(); + + assertThat(assertToken.isPresent()).isTrue(); + assertThat(assertToken.get()).isEqualTo(refreshToken); + + } + + // 이전 시간 토큰에 대한 검증 + @Test + @DisplayName("토큰에 대한 검증(시간, 유효성)") + void validateExpire() { + + // Given + String testToken = Jwts.builder() + .signWith(key) + .expiration(Date.from(Instant.now().minusMillis(10000L))) + .compact(); + + // When + boolean validate = jwtTokenUtil.validate(testToken); + + // Then + assertThat(validate).isFalse(); + + } +} \ No newline at end of file diff --git a/src/test/java/com/curateme/claco/clacobook/repository/ClacoBookRepositoryTest.java b/src/test/java/com/curateme/claco/clacobook/repository/ClacoBookRepositoryTest.java new file mode 100644 index 00000000..556eaad1 --- /dev/null +++ b/src/test/java/com/curateme/claco/clacobook/repository/ClacoBookRepositoryTest.java @@ -0,0 +1,61 @@ +package com.curateme.claco.clacobook.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; + +import jakarta.persistence.EntityManager; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ClacoBookRepositoryTest { + + @Autowired + EntityManager entityManager; + @Autowired + ClacoBookRepository clacoBookRepository; + + @Test + void findClacoBookById() { + // Given + String testString = "test"; + + Member testMember = Member.builder() + .email("test@test.com") + .role(Role.MEMBER) + .socialId(1L) + .build(); + + entityManager.persist(testMember); + + ClacoBook testBook = ClacoBook.builder() + .title(testString) + .color(testString) + .member(testMember) + .build(); + + entityManager.persist(testBook); + + Long testId = testBook.getId(); + + // When + Optional result = clacoBookRepository.findClacoBookById(testId); + + // Then + assertThat(result.isPresent()).isTrue(); + assertThat(result.get().getMember()).isEqualTo(testMember); + assertThat(result.get().getTitle()).isEqualTo(testString); + + } +} \ No newline at end of file diff --git a/src/test/java/com/curateme/claco/clacobook/service/ClacoBookServiceTest.java b/src/test/java/com/curateme/claco/clacobook/service/ClacoBookServiceTest.java new file mode 100644 index 00000000..dcd442b6 --- /dev/null +++ b/src/test/java/com/curateme/claco/clacobook/service/ClacoBookServiceTest.java @@ -0,0 +1,221 @@ +package com.curateme.claco.clacobook.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.AdditionalAnswers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.clacobook.domain.dto.request.UpdateClacoBookRequest; +import com.curateme.claco.clacobook.domain.dto.response.ClacoBookResponse; +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.clacobook.repository.ClacoBookRepository; +import com.curateme.claco.global.entity.ActiveStatus; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; +import com.curateme.claco.member.repository.MemberRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class ClacoBookServiceTest { + + @Mock + private MemberRepository memberRepository; + @Mock + private ClacoBookRepository clacoBookRepository; + @Mock + private SecurityContextUtil securityContextUtil; + @InjectMocks + private ClacoBookServiceImpl clacoBookService; + + private final Long testId = 1L; + private final String testString = "test"; + + @Test + @DisplayName("ClacoBook 생성") + void createClacoBook() { + // Given + String bookColor = "#8F9AF8"; + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + Member testMember = Member.builder() + .id(testId) + .email("test@test.com") + .nickname("test") + .role(Role.MEMBER) + .socialId(testId) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(testId); + when(memberRepository.findMemberByIdWithClacoBook(testId)).thenReturn(Optional.of(testMember)); + when(clacoBookRepository.save(any(ClacoBook.class))).then(AdditionalAnswers.returnsFirstArg()); + UpdateClacoBookRequest request = UpdateClacoBookRequest.builder() + .color(bookColor) + .build(); + // When + ClacoBookResponse result = clacoBookService.createClacoBook(request); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(jwtMemberDetailMock).getMemberId(); + verify(memberRepository).findMemberByIdWithClacoBook(testId); + + String bookTitle = "test님의 이야기"; + + assertThat(result.getId()).isNull(); + assertThat(result.getTitle()).isEqualTo(bookTitle); + assertThat(result.getColor()).isEqualTo(bookColor); + + } + + @Test + @DisplayName("ClacoBook 리스트 조회") + void readClacoBooks() { + // Given + Member testMember = Member.builder() + .id(testId) + .email("test@test.com") + .nickname("test") + .role(Role.MEMBER) + .socialId(testId) + .build(); + + ClacoBook testBook1 = ClacoBook.builder() + .member(testMember) + .id(testId) + .title(testString) + .color(testString) + .build(); + + ClacoBook testBook2 = ClacoBook.builder() + .member(testMember) + .id(testId) + .title(testString) + .color(testString) + .build(); + + testMember.addClacoBook(testBook1); + testMember.addClacoBook(testBook2); + + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(testId); + when(memberRepository.findMemberByIdWithClacoBook(testId)).thenReturn(Optional.of(testMember)); + + // When + List result = clacoBookService.readClacoBooks(); + + // Then + verify(jwtMemberDetailMock).getMemberId(); + verify(memberRepository).findMemberByIdWithClacoBook(testId); + + assertThat(result.size()).isEqualTo(2); + + } + + @Test + @DisplayName("ClacoBook 수정") + void updateClacoBook() { + // Given + Member testMember = Member.builder() + .id(testId) + .email("test@test.com") + .nickname("test") + .role(Role.MEMBER) + .socialId(testId) + .build(); + + ClacoBook testBook1 = ClacoBook.builder() + .member(testMember) + .id(testId) + .title(testString) + .color(testString) + .build(); + + testMember.addClacoBook(testBook1); + + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(testId); + when(clacoBookRepository.findClacoBookById(testId)).thenReturn(Optional.of(testBook1)); + + String edit = "new"; + + UpdateClacoBookRequest request = UpdateClacoBookRequest.builder() + .id(testId) + .title(edit) + .color(edit) + .build(); + + // When + ClacoBookResponse result = clacoBookService.updateClacoBook(request); + + // Then + verify(jwtMemberDetailMock).getMemberId(); + verify(clacoBookRepository).findClacoBookById(testId); + + assertThat(result.getId()).isEqualTo(testId); + assertThat(result.getTitle()).isEqualTo(edit); + assertThat(result.getColor()).isEqualTo(edit); + + } + + @Test + @DisplayName("ClacoBook 삭제") + void deleteClacoBook() { + // Given + Member testMember = Member.builder() + .id(testId) + .email("test@test.com") + .nickname("test") + .role(Role.MEMBER) + .socialId(testId) + .build(); + + ClacoBook testBook1 = ClacoBook.builder() + .member(testMember) + .id(testId) + .title(testString) + .color(testString) + .build(); + + testMember.addClacoBook(testBook1); + + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(testId); + when(clacoBookRepository.findClacoBookById(testId)).thenReturn(Optional.of(testBook1)); + doAnswer(invocationOnMock -> { + ClacoBook tmpBook = invocationOnMock.getArgument(0); + tmpBook.updateActiveStatus(ActiveStatus.DELETED); + return tmpBook; + }).when(clacoBookRepository).delete(any(ClacoBook.class)); + + // When + clacoBookService.deleteClacoBook(testId); + + // Then + verify(jwtMemberDetailMock).getMemberId(); + verify(clacoBookRepository).findClacoBookById(testId); + + assertThat(testBook1.getActiveStatus()).isEqualTo(ActiveStatus.DELETED); + + } +} \ No newline at end of file diff --git a/src/test/java/com/curateme/claco/concert/repository/ConcertCategoryRepositoryTest.java b/src/test/java/com/curateme/claco/concert/repository/ConcertCategoryRepositoryTest.java new file mode 100644 index 00000000..e23197ca --- /dev/null +++ b/src/test/java/com/curateme/claco/concert/repository/ConcertCategoryRepositoryTest.java @@ -0,0 +1,81 @@ +package com.curateme.claco.concert.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.curateme.claco.concert.domain.entity.Category; +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.concert.domain.entity.ConcertCategory; +import java.time.LocalDate; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@Slf4j +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ConcertCategoryRepositoryTest { + + @Autowired + private ConcertCategoryRepository concertCategoryRepository; + + @Autowired + private ConcertRepository concertRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @BeforeEach + void setUp() { + // 1. Concert 생성 + concertRepository.save( + Concert.builder() + .mt20id("C12345") + .prfnm("Test Concert") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 1, 31)) + .fcltynm("Grand Theater") + .build() + ); + + // 2. Category 생성 + categoryRepository.save( + Category.builder() + .category("웅장한") + .imageUrl("xxx") + .build() + ); + + categoryRepository.save( + Category.builder() + .category("현대적인") + .imageUrl("xxx") + .build() + ); + + } + + @Test + void testFindCategoryIdsByCategoryName() { + // Given + List concerts = concertRepository.findAll(); + assertThat(concerts).isNotEmpty(); + Long concertId = concerts.get(0).getId(); + + // When + List categoryIds = concertCategoryRepository.findCategoryIdsByCategoryName(concertId); + + // Then + assertThat(categoryIds).isNotNull(); + if (!categoryIds.isEmpty()) { + assertThat(categoryIds).containsExactlyInAnyOrder(1L, 2L); + } else { + log.info("No categories found for concertId: {}", concertId); + } + } + +} + diff --git a/src/test/java/com/curateme/claco/concert/repository/ConcertLikeRepositoryTest.java b/src/test/java/com/curateme/claco/concert/repository/ConcertLikeRepositoryTest.java new file mode 100644 index 00000000..f03f2f95 --- /dev/null +++ b/src/test/java/com/curateme/claco/concert/repository/ConcertLikeRepositoryTest.java @@ -0,0 +1,154 @@ +package com.curateme.claco.concert.repository; + +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.concert.domain.entity.ConcertLike; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; +import com.curateme.claco.member.repository.MemberRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.List; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ConcertLikeRepositoryTest { + + @Autowired + private ConcertLikeRepository concertLikeRepository; + + @Autowired + private ConcertRepository concertRepository; + + @Autowired + private MemberRepository memberRepository; + + @BeforeEach + void setUp() { + // 1. Member 생성 및 저장 + Member testMember = memberRepository.save( + Member.builder() + .email("test@test.com") + .role(Role.MEMBER) + .socialId(1L) + .build() + ); + + // 2. Concert 생성 및 저장 + Concert concert1 = concertRepository.save( + Concert.builder() + .mt20id("C12345") + .prfnm("Concert A") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 1, 31)) + .fcltynm("Grand Theater") + .build() + ); + + + // 3. ConcertLike 생성 및 저장 + concertLikeRepository.save( + ConcertLike.builder() + .member(testMember) // 이미 저장된 testMember를 사용 + .concert(concert1) + .build() + ); + + } + + + + @Test + void testExistsByConcertId() { + // Given + Long concertId = concertRepository.findAll().get(0).getId(); + + // When + boolean exists = concertLikeRepository.existsByConcertId(concertId); + + // Then + assertThat(exists).isTrue(); + } + + @Test + void testFindMostRecentLikedConcert() { + // Given + Long userId = memberRepository.findAll().get(0).getId(); + + // When + Pageable pageable = PageRequest.of(0, 1); + Long concertId = concertLikeRepository.findMostRecentLikedConcert(userId, pageable).getContent().stream().findFirst().orElse(null); + + // Then + assertThat(concertId).isNotNull(); + } + + @Test + void testFindConcertIdsByMemberId() { + // Given + Long userId = memberRepository.findAll().get(0).getId(); + + // When + List concertIds = concertLikeRepository.findConcertIdsByMemberId(userId); + + // Then + assertThat(concertIds).isNotNull(); + assertThat(concertIds).isNotEmpty(); + } + + @Test + void testFindTopConcertIdsByLikeCount() { + // Given + int topCount = 5; + + // When + List topConcertIds = concertLikeRepository.findTopConcertIdsByLikeCount(Pageable.ofSize(topCount)); + + // Then + assertThat(topConcertIds).isNotNull(); + assertThat(topConcertIds).hasSizeLessThanOrEqualTo(topCount); + } + + @Test + void testFindByMemberAndConcert() { + // Given + Member testMember = memberRepository.findAll().get(0); + Concert testConcert = concertRepository.findAll().get(0); + + // When + Optional concertLike = concertLikeRepository.findByMemberAndConcert(testMember, testConcert); + + // Then + assertThat(concertLike).isPresent(); + assertThat(concertLike.get().getMember().getId()).isEqualTo(testMember.getId()); + assertThat(concertLike.get().getConcert().getId()).isEqualTo(testConcert.getId()); + } + + @Test + void testExistsByConcertIdAndMemberId() { + // Given + Member testMember = memberRepository.findAll().get(0); + Concert testConcert = concertRepository.findAll().get(0); + + // When + boolean exists = concertLikeRepository.existsByConcertIdAndMemberId(testConcert.getId(), testMember.getId()); + + // Then + assertThat(exists).isTrue(); + } + + + +} diff --git a/src/test/java/com/curateme/claco/concert/repository/ConcertRepositoryTest.java b/src/test/java/com/curateme/claco/concert/repository/ConcertRepositoryTest.java new file mode 100644 index 00000000..066a0298 --- /dev/null +++ b/src/test/java/com/curateme/claco/concert/repository/ConcertRepositoryTest.java @@ -0,0 +1,164 @@ +package com.curateme.claco.concert.repository; + +import com.curateme.claco.concert.domain.entity.Concert; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ConcertRepositoryTest { + + @Autowired + private ConcertRepository concertRepository; + + @BeforeEach + void setUp() { + concertRepository.saveAll(Arrays.asList( + Concert.builder() + .mt20id("C12345") + .prfnm("웅장한 연극") + .prfpdfrom(LocalDate.now().minusDays(10)) + .prfpdto(LocalDate.now().plusDays(5)) + .fcltynm("서울극장") + .area("서울특별시") + .genrenm("연극") + .build(), + Concert.builder() + .mt20id("C12346") + .prfnm("현대적인 뮤지컬") + .prfpdfrom(LocalDate.now().minusDays(20)) + .prfpdto(LocalDate.now().plusDays(20)) + .fcltynm("대학로극장") + .area("서울특별시") + .genrenm("뮤지컬") + .build(), + Concert.builder() + .mt20id("C12347") + .prfnm("클래식 음악회") + .prfpdfrom(LocalDate.now().minusDays(15)) + .prfpdto(LocalDate.now()) + .fcltynm("예술의전당") + .area("서울특별시") + .genrenm("음악회") + .build() + )); + } + + @Test + void testFindByIdIn() { + // Given + List ids = concertRepository.findAll().stream() + .limit(3) + .map(Concert::getId) + .toList(); + PageRequest pageable = PageRequest.of(0, 10); + + // When + Page result = concertRepository.findByIdIn(ids, pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).isNotEmpty(); + assertThat(result.getContent().size()).isEqualTo(ids.size()); + } + + @Test + void testFindConcertIdsByFilters() { + // Given + String area = "서울특별시"; + LocalDate startDate = LocalDate.now().minusDays(30); + LocalDate endDate = LocalDate.now().plusDays(30); + List categories = Arrays.asList("웅장한", "현대적인"); + + // When + List concertIds = concertRepository.findConcertIdsByFilters(area, startDate, endDate, categories); + + // Then + assertThat(concertIds).isNotNull(); + } + + @Test + void testFindConcertIdsBySearchQuery() { + // Given + String query = "연극"; + + // When + List concertIds = concertRepository.findConcertIdsBySearchQuery(query); + + // Then + assertThat(concertIds).isNotNull(); + assertThat(concertIds).isNotEmpty(); + } + + @Test + void testFindConcertById() { + // Given + Long concertId = concertRepository.findAll().get(0).getId(); + + // When + Concert concert = concertRepository.findConcertById(concertId); + + // Then + assertThat(concert).isNotNull(); + assertThat(concert.getId()).isEqualTo(concertId); + } + + @Test + void testFindBySearchQuery() { + // Given + String query = "뮤지컬"; + PageRequest pageable = PageRequest.of(0, 10); + + // When + Page result = concertRepository.findBySearchQuery(query, pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).isNotEmpty(); + assertThat(result.getContent().get(0).getPrfnm()).contains("뮤지컬"); + } + + @Test + void testFindConcertsByFilters() { + // Given + String area = "서울특별시"; + LocalDate startDate = LocalDate.now().minusDays(30); + LocalDate endDate = LocalDate.now().plusDays(30); + List categories = Arrays.asList("웅장한", "현대적인"); + PageRequest pageable = PageRequest.of(0, 10); + + // When + Page result = concertRepository.findConcertsByFilters(area, startDate, endDate, categories, pageable); + + // Then + assertThat(result).isNotNull(); + } + + @Test + void testFindConcertsByGenreWithPagination() { + // Given + String genre = "음악회"; + PageRequest pageable = PageRequest.of(0, 10); + + // When + Page result = concertRepository.findConcertsByGenreWithPagination(genre, pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).isNotEmpty(); + assertThat(result.getContent().get(0).getGenrenm()).isEqualTo(genre); + } +} diff --git a/src/test/java/com/curateme/claco/concert/service/ConcertServiceTest.java b/src/test/java/com/curateme/claco/concert/service/ConcertServiceTest.java new file mode 100644 index 00000000..25d4bff7 --- /dev/null +++ b/src/test/java/com/curateme/claco/concert/service/ConcertServiceTest.java @@ -0,0 +1,412 @@ +package com.curateme.claco.concert.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.concert.domain.dto.response.*; +import com.curateme.claco.concert.domain.entity.*; +import com.curateme.claco.concert.repository.*; +import com.curateme.claco.global.response.PageResponse; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.repository.MemberRepository; +import com.curateme.claco.review.domain.entity.TicketReview; +import com.curateme.claco.review.repository.TicketReviewRepository; +import java.math.BigDecimal; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.*; +import com.curateme.claco.member.domain.entity.Role; + + +import java.time.LocalDate; +import java.util.*; + +@ExtendWith(MockitoExtension.class) +class ConcertServiceTest { + + @Mock private ConcertRepository concertRepository; + @Mock private ConcertCategoryRepository concertCategoryRepository; + @Mock private CategoryRepository categoryRepository; + @Mock private MemberRepository memberRepository; + @Mock private SecurityContextUtil securityContextUtil; + @Mock private ConcertLikeRepository concertLikeRepository; + @Mock private TicketReviewRepository ticketReviewRepository; + + @InjectMocks private ConcertServiceImpl concertService; + + private final Pageable pageable = PageRequest.of(0, 10, Sort.by("prfpdfrom").ascending()); + + @Test + @DisplayName("장르 기반 콘서트 조회") + void testGetConcertInfos() { + + Long memberId = 10L; + + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + // Given + String genre = "Classical"; + Concert mockConcert = Concert.builder() + .id(1L) + .prfnm("클래식 콘서트") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .genrenm("Classical") + .build(); + + when(concertRepository.findConcertsByGenreWithPagination(eq(genre), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(mockConcert), pageable, 1)); + + when(concertCategoryRepository.findCategoryIdsByCategoryName(1L)) + .thenReturn(List.of(1L)); + when(categoryRepository.findAllById(List.of(1L))) + .thenReturn(List.of( + Category.builder().id(1L).category("웅장한").imageUrl("image-url-1").build() + )); + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(memberId); + + // When + PageResponse result = concertService.getConcertInfos(genre, "asc", pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getListPageResponse()).hasSize(1); + assertThat(result.getListPageResponse().get(0).getPrfnm()).isEqualTo("클래식 콘서트"); + verify(concertRepository, times(1)).findConcertsByGenreWithPagination(eq(genre), any(Pageable.class)); + } + + @Test + @DisplayName("필터 기반 콘서트 조회") + void testGetConcertInfosWithFilter() { + // Given + List categories = List.of("웅장한"); + Concert mockConcert = Concert.builder() + .id(1L) + .prfnm("테스트 콘서트") + .area("서울특별시") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .build(); + + // Mock repository responses + when(concertRepository.findConcertsByFiltersWithoutPaging( + eq(null), + eq(LocalDate.of(2023, 1, 1)), + eq(LocalDate.of(2024, 12, 31)), + eq(categories))) + .thenReturn(List.of(mockConcert)); + + when(concertCategoryRepository.findCategoryIdsByCategoryName(1L)) + .thenReturn(List.of(1L)); + + when(categoryRepository.findAllById(List.of(1L))) + .thenReturn(List.of( + Category.builder().id(1L).category("웅장한").imageUrl("image-url-1").build() + )); + + // Mock pageable + Pageable pageable = PageRequest.of(0, 10); + + // When + PageResponse result = concertService.getConcertInfosWithFilter( + 0.0, 100.0, null, + LocalDate.of(2023, 1, 1), LocalDate.of(2024, 12, 31), + "asc", categories, pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getListPageResponse()).hasSize(1); + assertThat(result.getListPageResponse().get(0).getPrfnm()).isEqualTo("테스트 콘서트"); + assertThat(result.getListPageResponse().get(0).getCategories()).hasSize(1); + assertThat(result.getListPageResponse().get(0).getCategories().get(0).getCategory()).isEqualTo("웅장한"); + } + + + @Test + void testGetConcertDetailWithCategories() { + // Given + Long concertId = 1L; + Long memberId = 10L; + + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + Member testMember = Member.builder() + .id(memberId) + .nickname("test_user") + .email("test@test.com") + .role(Role.MEMBER) + .build(); + + Concert mockConcert = Concert.builder() + .id(concertId) + .prfnm("테스트 콘서트") + .genrenm("Classical") + .build(); + + TicketReview mockReview = TicketReview.builder() + .id(1L) + .starRate(BigDecimal.valueOf(4.5)) + .member(testMember) + .build(); + + // Mock Category 객체 생성 + Category mockCategory = mock(Category.class); + lenient().when(mockCategory.getId()).thenReturn(1L); + lenient().when(mockCategory.getCategory()).thenReturn("웅장한"); + lenient().when(mockCategory.getImageUrl()).thenReturn("image-url-1"); + + // Mock 설정 + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(memberId); + when(concertRepository.findConcertById(concertId)).thenReturn(mockConcert); + when(ticketReviewRepository.findByConcertId(concertId)).thenReturn(List.of(mockReview.getId())); + when(ticketReviewRepository.findAllById(List.of(mockReview.getId()))).thenReturn(List.of(mockReview)); + when(concertCategoryRepository.findCategoryIdsByCategoryName(concertId)).thenReturn(List.of(1L)); + when(categoryRepository.findAllById(List.of(1L))).thenReturn(List.of(mockCategory)); + when(concertLikeRepository.existsByConcertIdAndMemberId(concertId, memberId)).thenReturn(true); + + // When + ConcertDetailResponse result = concertService.getConcertDetailWithCategories(concertId); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(jwtMemberDetailMock).getMemberId(); + verify(concertRepository).findConcertById(concertId); + verify(ticketReviewRepository).findByConcertId(concertId); + verify(ticketReviewRepository).findAllById(List.of(mockReview.getId())); + verify(concertCategoryRepository).findCategoryIdsByCategoryName(concertId); + verify(categoryRepository).findAllById(List.of(1L)); + verify(concertLikeRepository).existsByConcertIdAndMemberId(concertId, memberId); + + assertThat(result).isNotNull(); + assertThat(result.getPrfnm()).isEqualTo("테스트 콘서트"); + assertThat(result.getCategories()).hasSize(1); + assertThat(result.getCategories().get(0).getCategory()).isEqualTo("웅장한"); + assertThat(result.getCategories().get(0).getImageURL()).isEqualTo("image-url-1"); + assertThat(result.isLiked()).isTrue(); + } + + + @Test + @DisplayName("콘서트 자동 완성 결과 조회") + void testGetAutoComplete() { + // Given + String query = "클래식"; + Concert mockConcert1 = Concert.builder() + .id(1L) + .prfnm("클래식 콘서트 1") + .build(); + + Concert mockConcert2 = Concert.builder() + .id(2L) + .prfnm("클래식 콘서트 2") + .build(); + + when(concertRepository.findConcertIdsBySearchQuery(query)).thenReturn(List.of(1L, 2L)); + when(concertRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(mockConcert1, mockConcert2)); + + // When + List result = concertService.getAutoComplete(query); + + // Then + assertThat(result).isNotNull(); + assertThat(result).hasSize(2); + assertThat(result.get(0).getPrfnm()).isEqualTo("클래식 콘서트 1"); + assertThat(result.get(1).getPrfnm()).isEqualTo("클래식 콘서트 2"); + + verify(concertRepository, times(1)).findConcertIdsBySearchQuery(query); + verify(concertRepository, times(1)).findAllById(List.of(1L, 2L)); + } + + @Test + @DisplayName("콘서트 좋아요 등록 및 취소") + void testPostLikes() { + // Given + Long concertId = 1L; + Long memberId = 10L; + + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + // Mock Member와 Concert 객체 생성 + Member mockMember = Member.builder() + .id(memberId) + .nickname("test_user") + .build(); + + Concert mockConcert = Concert.builder() + .id(concertId) + .prfnm("테스트 콘서트") + .build(); + + ConcertLike mockLike = ConcertLike.builder() + .member(mockMember) + .concert(mockConcert) + .build(); + + // Mock 설정 + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(memberId); + when(memberRepository.getById(memberId)).thenReturn(mockMember); + when(concertRepository.findById(concertId)).thenReturn(Optional.of(mockConcert)); + + // 좋아요가 이미 있는 상태 Mock + when(concertLikeRepository.findByMemberAndConcert(eq(mockMember), eq(mockConcert))) + .thenReturn(Optional.of(mockLike)); + + // When - 좋아요 취소 + String result = concertService.postLikes(concertId); + + // Then - 좋아요 취소 확인 + assertThat(result).isEqualTo("좋아요가 취소되었습니다."); + verify(concertLikeRepository, times(1)).delete(mockLike); + + // 좋아요가 없는 상태 Mock + when(concertLikeRepository.findByMemberAndConcert(eq(mockMember), eq(mockConcert))) + .thenReturn(Optional.empty()); + + // When - 좋아요 등록 + String result2 = concertService.postLikes(concertId); + + // Then - 좋아요 등록 확인 + assertThat(result2).isEqualTo("좋아요가 등록되었습니다."); + verify(concertLikeRepository, times(1)).save(any(ConcertLike.class)); + } + + + + @Test + @DisplayName("회원이 좋아요한 콘서트 조회") + void testGetLikedConcert() { + // Given + Long memberId = 10L; + Long concertId = 1L; + + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + Concert mockConcert = Concert.builder() + .id(concertId) + .prfnm("테스트 콘서트") + .genrenm("Classical") + .build(); + + Category mockCategory = Category.builder() + .id(1L) + .category("웅장한") + .imageUrl("image-url-1") + .build(); + String query = "all"; + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(memberId); + when(concertLikeRepository.findConcertIdsByMemberId(memberId)).thenReturn(List.of(concertId)); + when(concertRepository.findConcertById(concertId)).thenReturn(mockConcert); + when(concertRepository.findConcertIdsBySearchQuery(query)).thenReturn(List.of(concertId)); + when(concertCategoryRepository.findCategoryIdsByCategoryName(concertId)).thenReturn(List.of(1L)); + when(categoryRepository.findAllById(List.of(1L))).thenReturn(List.of(mockCategory)); + + // When + List result = concertService.getLikedConcert("all", "all"); + + // Then + assertThat(result).isNotNull(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getPrfnm()).isEqualTo("테스트 콘서트"); + assertThat(result.get(0).getCategories()).hasSize(1); + assertThat(result.get(0).getCategories().get(0).getCategory()).isEqualTo("웅장한"); + + verify(concertLikeRepository).findConcertIdsByMemberId(memberId); + verify(concertRepository).findConcertById(concertId); + verify(concertCategoryRepository).findCategoryIdsByCategoryName(concertId); + verify(categoryRepository).findAllById(List.of(1L)); + + } + + + @Test + @DisplayName("콘서트 검색 결과 조회") + void testGetSearchConcert() { + // Given + String query = "클래식"; + String direction = "asc"; + + Concert mockConcert1 = Concert.builder() + .id(1L) + .prfnm("클래식 콘서트 1") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .build(); + + Concert mockConcert2 = Concert.builder() + .id(2L) + .prfnm("클래식 콘서트 2") + .prfpdfrom(LocalDate.of(2023, 1, 1)) + .prfpdto(LocalDate.of(2023, 12, 31)) + .build(); + + Page concertPage = new PageImpl<>(List.of(mockConcert1, mockConcert2), pageable, 2); + + when(concertRepository.findBySearchQuery(eq(query), any(Pageable.class))) + .thenReturn(concertPage); + + when(concertCategoryRepository.findCategoryIdsByCategoryName(anyLong())) + .thenReturn(List.of(1L)); + when(categoryRepository.findAllById(anyList())) + .thenReturn(List.of( + Category.builder().id(1L).category("웅장한").imageUrl("image-url-1").build() + )); + + // When + PageResponse result = concertService.getSearchConcert(query, direction, pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getListPageResponse()).hasSize(2); + assertThat(result.getListPageResponse().get(0).getPrfnm()).isEqualTo("클래식 콘서트 1"); + assertThat(result.getListPageResponse().get(1).getPrfnm()).isEqualTo("클래식 콘서트 2"); + verify(concertRepository, times(1)).findBySearchQuery(eq(query), any(Pageable.class)); + } + + @Test + @DisplayName("검색어 및 장르로 좋아요 콘서트 필터링") + void testFilterConcertsByQueryAndGenre() { + // Given + List concertLikedIds = List.of(1L, 2L, 3L); + String query = "클래식"; + String genre = "Classical"; + + Concert mockConcert1 = Concert.builder() + .id(1L) + .prfnm("클래식 콘서트 1") + .genrenm("Classical") + .build(); + + Concert mockConcert2 = Concert.builder() + .id(2L) + .prfnm("클래식 콘서트 2") + .genrenm("Pop") + .build(); + + when(concertRepository.findConcertIdsBySearchQuery(query)) + .thenReturn(List.of(1L, 2L)); + when(concertRepository.findConcertById(1L)) + .thenReturn(mockConcert1); + when(concertRepository.findConcertById(2L)) + .thenReturn(mockConcert2); + + // When + List result = concertService.filterConcertsByQueryAndGenre(concertLikedIds, query, genre); + + // Then + assertThat(result).isNotNull(); + assertThat(result).hasSize(1); // Only mockConcert1 matches the genre "Classical" + assertThat(result.get(0)).isEqualTo(1L); + verify(concertRepository, times(1)).findConcertIdsBySearchQuery(query); + verify(concertRepository, times(1)).findConcertById(1L); + verify(concertRepository, times(1)).findConcertById(2L); + } + +} diff --git a/src/test/java/com/curateme/claco/member/repository/MemberRepositoryTest.java b/src/test/java/com/curateme/claco/member/repository/MemberRepositoryTest.java new file mode 100644 index 00000000..e9df7f0b --- /dev/null +++ b/src/test/java/com/curateme/claco/member/repository/MemberRepositoryTest.java @@ -0,0 +1,184 @@ +package com.curateme.claco.member.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.transaction.annotation.Transactional; + +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; +import com.curateme.claco.preference.domain.entity.Preference; +import com.curateme.claco.preference.domain.entity.RegionPreference; +import com.curateme.claco.preference.domain.entity.TypePreference; + +import jakarta.persistence.EntityManager; +import lombok.extern.slf4j.Slf4j; + +/** + * @author : 이 건 + * @date : 2024.10.18 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.18 이 건 최초 생성 + * 2024.11.05 이 건 추가된 메서드 테스트 추가 + */ +@Slf4j +@Transactional +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class MemberRepositoryTest { + + @Autowired + MemberRepository memberRepository; + @Autowired + EntityManager entityManager; + + private final Role testRole = Role.MEMBER; + private final Long testLong = 1L; + private final String testString = "test"; + + @Test + @DisplayName("소셜 아이디로 멤버 찾기") + void findMemberBySocialId() { + // Given + Member testMember = Member.builder() + .email("test@test.com") + .nickname(testString) + .role(testRole) + .socialId(testLong) + .profileImage(testString) + .build(); + + entityManager.persist(testMember); + + // When + Optional assertMember = memberRepository.findMemberBySocialId(testLong); + + // Then + assertThat(assertMember.isPresent()).isTrue(); + assertThat(assertMember.get()).isEqualTo(testMember); + assertThat(assertMember.get().getSocialId()).isEqualTo(testMember.getSocialId()); + + } + + @Test + @DisplayName("닉네임으로 멤버 찾기") + void findMemberByNickname() { + // Given + Member testMember = Member.builder() + .email("test@test.com") + .nickname(testString) + .role(testRole) + .socialId(testLong) + .profileImage(testString) + .build(); + + entityManager.persist(testMember); + + // When + Optional assertMember = memberRepository.findMemberByNickname(testString); + + // Then + assertThat(assertMember.isPresent()).isTrue(); + assertThat(assertMember.get()).isEqualTo(testMember); + assertThat(assertMember.get().getNickname()).isEqualTo(testMember.getNickname()); + + } + + @Test + @DisplayName("아이디로 클라코북과 함께 멤버 찾기") + void findMemberByNicknameWithClacoBook() { + // Given + Member testMember = Member.builder() + .email("test@test.com") + .nickname(testString) + .role(testRole) + .socialId(testLong) + .profileImage(testString) + .build(); + + entityManager.persist(testMember); + + Long curId = testMember.getId(); + + ClacoBook clacoBook1 = ClacoBook.builder() + .title(testString) + .color(testString) + .member(testMember) + .build(); + + ClacoBook clacoBook2 = ClacoBook.builder() + .title(testString) + .color(testString) + .member(testMember) + .build(); + + entityManager.persist(clacoBook1); + entityManager.persist(clacoBook2); + + testMember.addClacoBook(clacoBook1); + testMember.addClacoBook(clacoBook2); + + // When + Optional result = memberRepository.findMemberByIdWithClacoBook(curId); + + // Then + assertThat(result.isPresent()).isTrue(); + assertThat(result.get().getClacoBooks().size()).isEqualTo(2); + + } + + @Test + @DisplayName("취향 정보 함께 조회") + void findMemberWithPreference() { + // Given + Member testMember = Member.builder() + .email("test@test.com") + .nickname(testString) + .role(testRole) + .socialId(testLong) + .profileImage(testString) + .build(); + entityManager.persist(testMember); + + Preference preference = Preference.builder() + .member(testMember) + .build(); + entityManager.persist(preference); + testMember.updatePreference(preference); + + RegionPreference regionPreference = RegionPreference.builder() + .preference(preference) + .regionName(testString) + .build(); + entityManager.persist(regionPreference); + preference.addRegionPreference(regionPreference); + TypePreference typePreference = TypePreference.builder() + .preference(preference) + .typeContent(testString) + .build(); + entityManager.persist(typePreference); + preference.addTypeReference(typePreference); + + // When + Optional result = memberRepository.findMemberByIdIs(testMember.getId()); + + // Then + assertThat(result.isPresent()).isTrue(); + assertThat(result.get().getPreference()).isEqualTo(preference); + assertThat(result.get().getPreference().getRegionPreferences()).contains(regionPreference); + assertThat(result.get().getPreference().getTypePreferences()).contains(typePreference); + + } + + +} \ No newline at end of file diff --git a/src/test/java/com/curateme/claco/member/service/MemberServiceTest.java b/src/test/java/com/curateme/claco/member/service/MemberServiceTest.java new file mode 100644 index 00000000..b01dfd23 --- /dev/null +++ b/src/test/java/com/curateme/claco/member/service/MemberServiceTest.java @@ -0,0 +1,218 @@ +package com.curateme.claco.member.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.global.exception.BusinessException; +import com.curateme.claco.global.response.ApiStatus; +import com.curateme.claco.global.util.S3Util; +import com.curateme.claco.member.domain.dto.request.SignUpRequest; +import com.curateme.claco.member.domain.dto.response.MemberInfoResponse; +import com.curateme.claco.member.domain.entity.Gender; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; +import com.curateme.claco.member.repository.MemberRepository; +import com.curateme.claco.preference.domain.entity.Preference; +import com.curateme.claco.preference.domain.vo.CategoryPreferenceVO; +import com.curateme.claco.preference.domain.vo.RegionPreferenceVO; +import com.curateme.claco.preference.domain.vo.TypePreferenceVO; +import com.curateme.claco.preference.service.PreferenceService; + +import lombok.extern.slf4j.Slf4j; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + * 2024.11.05 이 건 추가 메서드에 대한 테스트 코드 생성 + */ +@Slf4j +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @Mock + private MemberRepository memberRepository; + @Mock + private SecurityContextUtil securityContextUtil; + @Mock + private PreferenceService preferenceService; + @Mock + private S3Util s3Util; + @InjectMocks + private MemberServiceV1 memberService; + + private final String testString = "test"; + private final Long testLong = 1L; + + @Test + @DisplayName("닉네임 중복 체크") + void checkNicknameValid() { + // Given + Member testMember = Member.builder() + .email(testString) + .nickname(testString) + .socialId(testLong) + .role(Role.MEMBER) + .build(); + + when(memberRepository.findMemberByNickname(testString)).thenReturn(Optional.of(testMember)); + + // When + BusinessException exception = Assertions.assertThrows(BusinessException.class, () -> { + memberService.checkNicknameValid(testString); + }); + + // Then + verify(memberRepository, times(1)).findMemberByNickname(testString); + assertThat(exception.getCode()).isEqualTo(ApiStatus.MEMBER_NICKNAME_DUPLICATE.getCode()); + + } + + @Test + @DisplayName("회원가입 테스트") + void signUpTest() { + // Given + List stringList = List.of("test1", "test2", "test3", "test4", "test5"); + + Integer testInteger = 10; + + Member testMember = Member.builder() + .id(testLong) + .email(testString) + .socialId(testLong) + .role(Role.SOCIAL) + .build(); + + Preference preferenceMock = mock(Preference.class); + + SignUpRequest testRequest = SignUpRequest.builder() + .nickname(testString) + .age(testInteger) + .gender(Gender.MALE) + .minPrice(testInteger) + .maxPrice(testInteger) + .categoryPreferences(stringList.stream() + .map(CategoryPreferenceVO::new) + .toList()) + .regionPreferences(stringList.stream() + .map(RegionPreferenceVO::new) + .toList()) + .typePreferences(stringList.stream() + .map(TypePreferenceVO::new) + .toList()) + .build(); + + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(testLong); + when(memberRepository.findById(testLong)).thenReturn(Optional.of(testMember)); + when(preferenceService.savePreference(testRequest)).thenReturn(preferenceMock); + + // When + memberService.signUp(testRequest); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(jwtMemberDetailMock).getMemberId(); + verify(memberRepository).findById(testLong); + verify(preferenceService).savePreference(testRequest); + + assertThat(testMember.getNickname()).isEqualTo(testString); + assertThat(testMember.getPreference()).isEqualTo(preferenceMock); + assertThat(testMember.getAge()).isEqualTo(testInteger); + assertThat(testMember.getRole()).isEqualTo(Role.MEMBER); + + } + + @Test + @DisplayName("회원 정보 조회") + void readMemberInfo() { + // Given + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + Member testMember = Member.builder() + .id(testLong) + .email(testString) + .nickname(testString) + .profileImage(testString) + .socialId(testLong) + .role(Role.SOCIAL) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(testLong); + when(memberRepository.findById(testLong)).thenReturn(Optional.of(testMember)); + + // When + MemberInfoResponse result = memberService.readMemberInfo(); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(jwtMemberDetailMock).getMemberId(); + verify(memberRepository).findById(testLong); + + assertThat(result.getNickname()).isEqualTo(testString); + assertThat(result.getImageUrl()).isEqualTo(testString); + + } + + @Test + @DisplayName("회원 정보 업데이트") + void updateMemberInfo() throws IOException { + // Given + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + String updateString = "update"; + String testUrlString = "test/url"; + MultipartFile mockFile = mock(MultipartFile.class); + + Member testMember = Member.builder() + .id(testLong) + .email(testString) + .nickname(testString) + .profileImage(testString) + .socialId(testLong) + .role(Role.SOCIAL) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(testLong); + when(memberRepository.findById(testLong)).thenReturn(Optional.of(testMember)); + when(s3Util.uploadImage(any(MultipartFile.class), any(String.class))).thenReturn(testUrlString); + + // When + MemberInfoResponse result = memberService.updateMemberInfo(updateString, mockFile); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(jwtMemberDetailMock).getMemberId(); + verify(memberRepository).findById(testLong); + verify(s3Util).uploadImage(any(MultipartFile.class), any(String.class)); + + assertThat(result.getNickname()).isEqualTo(updateString); + assertThat(result.getImageUrl()).isEqualTo(testUrlString); + assertThat(testMember.getNickname()).isEqualTo(updateString); + assertThat(testMember.getProfileImage()).isEqualTo(testUrlString); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/curateme/claco/preference/service/PreferenceServiceTest.java b/src/test/java/com/curateme/claco/preference/service/PreferenceServiceTest.java new file mode 100644 index 00000000..a991cff6 --- /dev/null +++ b/src/test/java/com/curateme/claco/preference/service/PreferenceServiceTest.java @@ -0,0 +1,270 @@ +package com.curateme.claco.preference.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.member.domain.dto.request.SignUpRequest; +import com.curateme.claco.member.domain.entity.Gender; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; +import com.curateme.claco.member.repository.MemberRepository; +import com.curateme.claco.preference.domain.dto.request.PreferenceUpdateRequest; +import com.curateme.claco.preference.domain.dto.response.PreferenceInfoResponse; +import com.curateme.claco.preference.domain.entity.Preference; +import com.curateme.claco.preference.domain.entity.RegionPreference; +import com.curateme.claco.preference.domain.entity.TypePreference; +import com.curateme.claco.preference.domain.vo.CategoryPreferenceVO; +import com.curateme.claco.preference.domain.vo.RegionPreferenceVO; +import com.curateme.claco.preference.domain.vo.TypePreferenceVO; +import com.curateme.claco.preference.repository.PreferenceRepository; +import com.curateme.claco.preference.repository.RegionPreferenceRepository; +import com.curateme.claco.preference.repository.TypePreferenceRepository; + +/** + * @author : 이 건 + * @date : 2024.10.22 + * @author devkeon(devkeon123@gmail.com) + * =========================================================== + * DATE AUTHOR NOTE + * ----------------------------------------------------------- + * 2024.10.22 이 건 최초 생성 + */ +@ExtendWith(MockitoExtension.class) +class PreferenceServiceTest { + + @Mock + private PreferenceRepository preferenceRepository; + @Mock + private SecurityContextUtil securityContextUtil; + @Mock + private MemberRepository memberRepository; + @Mock + private TypePreferenceRepository typePreferenceRepository; + @Mock + private RegionPreferenceRepository regionPreferenceRepository; + @InjectMocks + private PreferenceServiceImpl preferenceService; + + @Test + @DisplayName("선호도 정보 생성 메서드 테스트") + void savePreferenceTest() { + // Given + Long testId = 1L; + String testString = "test"; + + JwtMemberDetail jwtMemberDetail = mock(JwtMemberDetail.class); + + Member testMember = Member.builder() + .email("test") + .socialId(testId) + .role(Role.MEMBER) + .build(); + + List stringList = List.of("test1", "test2", "test3", "test4", "test5"); + + SignUpRequest testRequest = SignUpRequest.builder() + .nickname(testString) + .age(10) + .gender(Gender.MALE) + .minPrice(10) + .maxPrice(100) + .categoryPreferences(stringList.stream() + .map(CategoryPreferenceVO::new) + .toList()) + .regionPreferences(stringList.stream() + .map(RegionPreferenceVO::new) + .toList()) + .typePreferences(stringList.stream() + .map(TypePreferenceVO::new) + .toList()) + .build(); + + doReturn(jwtMemberDetail).when(securityContextUtil).getContextMemberInfo(); + doReturn(testId).when(jwtMemberDetail).getMemberId(); + doReturn(Optional.of(testMember)).when(memberRepository).findById(testId); + when(preferenceRepository.save(any(Preference.class))).thenAnswer(invocation -> { + Preference preference = invocation.getArgument(0); + return Preference.builder() + .id(testId) + .member(preference.getMember()) + .minPrice(preference.getMinPrice()) + .maxPrice(preference.getMaxPrice()) + .preference1(preference.getPreference1()) + .preference2(preference.getPreference2()) + .preference3(preference.getPreference3()) + .preference4(preference.getPreference4()) + .preference5(preference.getPreference5()) + .build(); + }); + when(typePreferenceRepository.save(any(TypePreference.class))).thenReturn(null); + when(regionPreferenceRepository.save(any(RegionPreference.class))).thenReturn(null); + + // When + Preference preference = preferenceService.savePreference(testRequest); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(jwtMemberDetail).getMemberId(); + verify(memberRepository).findById(testId); + verify(preferenceRepository).save(any(Preference.class)); + + assertThat(preference.getId()).isEqualTo(testId); + assertThat(preference.getMember()).isEqualTo(testMember); + + } + + @Test + @DisplayName("선호도 조회 메서드 테스트") + void readPreference() { + // Given + Long testId = 1L; + Integer testInt = 0; + String testString = "test"; + JwtMemberDetail mockMemberDetail = mock(JwtMemberDetail.class); + + Member testMember = Member.builder() + .socialId(testId) + .role(Role.MEMBER) + .email("test@test.com") + .age(testInt) + .gender(Gender.MALE) + .build(); + + Preference testPrefer = Preference.builder() + .preference1(testString) + .preference2(testString) + .preference3(testString) + .preference4(testString) + .preference5(testString) + .member(testMember) + .minPrice(testInt) + .maxPrice(testInt) + .build(); + + RegionPreference testRegionPrefer = RegionPreference.builder() + .regionName(testString) + .preference(testPrefer) + .build(); + + TypePreference testTypePrefer = TypePreference.builder() + .typeContent(testString) + .preference(testPrefer) + .build(); + testPrefer.addRegionPreference(testRegionPrefer); + testPrefer.addTypeReference(testTypePrefer); + testMember.updatePreference(testPrefer); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(mockMemberDetail); + when(mockMemberDetail.getMemberId()).thenReturn(testId); + when(memberRepository.findMemberByIdIs(testId)).thenReturn(Optional.of(testMember)); + + // When + PreferenceInfoResponse result = preferenceService.readPreference(); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(mockMemberDetail).getMemberId(); + verify(memberRepository).findMemberByIdIs(testId); + + assertThat(result.getAge()).isEqualTo(testInt); + assertThat(result.getPreferRegions()).hasSize(1); + assertThat(result.getPreferCategories()).hasSize(5); + assertThat(result.getPreferTypes()).hasSize(1); + + } + + @Test + @DisplayName("선호도 수정 메서드 테스트") + void updatePreference() { + // Given + Long testId = 1L; + Integer testInt = 0; + String testString = "test"; + Integer resultInt = 1; + String resultString = "result"; + JwtMemberDetail mockMemberDetail = mock(JwtMemberDetail.class); + + Member testMember = Member.builder() + .socialId(testId) + .role(Role.MEMBER) + .email("test@test.com") + .age(testInt) + .gender(Gender.MALE) + .build(); + + Preference testPrefer = Preference.builder() + .preference1(testString) + .preference2(testString) + .preference3(testString) + .preference4(testString) + .preference5(testString) + .member(testMember) + .minPrice(testInt) + .maxPrice(testInt) + .build(); + + RegionPreference testRegionPrefer1 = RegionPreference.builder() + .regionName(testString) + .preference(testPrefer) + .build(); + + RegionPreference testRegionPrefer2 = RegionPreference.builder() + .regionName(resultString) + .preference(testPrefer) + .build(); + + TypePreference testTypePrefer = TypePreference.builder() + .typeContent(testString) + .preference(testPrefer) + .build(); + testPrefer.addRegionPreference(testRegionPrefer1); + testPrefer.addRegionPreference(testRegionPrefer2); + testPrefer.addTypeReference(testTypePrefer); + testMember.updatePreference(testPrefer); + + PreferenceUpdateRequest request = PreferenceUpdateRequest.builder() + .age(resultInt) + .gender(Gender.FEMALE) + .maxPrice(resultInt) + .minPrice(resultInt) + .regionPreferences(List.of()) + .typePreferences(List.of(TypePreferenceVO.fromEntity(testTypePrefer), new TypePreferenceVO(resultString))) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(mockMemberDetail); + when(mockMemberDetail.getMemberId()).thenReturn(testId); + when(memberRepository.findMemberByIdIs(testId)).thenReturn(Optional.of(testMember)); + doNothing().when(regionPreferenceRepository).delete(any(RegionPreference.class)); + when(typePreferenceRepository.save(any(TypePreference.class))).thenAnswer( + invocationOnMock -> invocationOnMock.getArgument(0)); + + // When + PreferenceInfoResponse result = preferenceService.updatePreference(request); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(mockMemberDetail).getMemberId(); + verify(memberRepository).findMemberByIdIs(testId); + verify(typePreferenceRepository).save(any(TypePreference.class)); + + assertThat(result.getPreferTypes()).hasSize(2); + assertThat(result.getPreferRegions()).hasSize(0); + assertThat(result.getAge()).isEqualTo(resultInt); + assertThat(result.getMinPrice()).isEqualTo(resultInt); + assertThat(result.getGender()).isEqualTo(Gender.FEMALE); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/curateme/claco/recommendation/RecommendationServiceTest.java b/src/test/java/com/curateme/claco/recommendation/RecommendationServiceTest.java new file mode 100644 index 00000000..8cfd8d62 --- /dev/null +++ b/src/test/java/com/curateme/claco/recommendation/RecommendationServiceTest.java @@ -0,0 +1,412 @@ +package com.curateme.claco.recommendation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.concert.repository.ConcertCategoryRepository; +import com.curateme.claco.concert.repository.ConcertLikeRepository; +import com.curateme.claco.concert.repository.ConcertRepository; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertResponseV3; +import com.curateme.claco.recommendation.domain.dto.RecommendationConcertsResponseV1; +import com.curateme.claco.recommendation.service.RecommendationServiceImpl; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class RecommendationServiceImplTest { + + @InjectMocks + private RecommendationServiceImpl recommendationService; + + @Mock + private ConcertRepository concertRepository; + + @Mock + private ConcertLikeRepository concertLikeRepository; + + @Mock + private ConcertCategoryRepository concertCategoryRepository; + + @Mock + private RestTemplate restTemplate; + + @Spy + @InjectMocks + private RecommendationServiceImpl spyRecommendationService; + + @Mock + private SecurityContextUtil securityContextUtil; + + + @BeforeEach + void setup() { + // Mock Concert entities + lenient().when(concertRepository.findConcertById(1L)).thenReturn( + Concert.builder() + .id(1L) + .prfnm("클래식 콘서트") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .genrenm("Classical") + .build() + ); + + lenient().when(concertRepository.findConcertById(2L)).thenReturn( + Concert.builder() + .id(2L) + .prfnm("클래식 콘서트 2") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .genrenm("Classical") + .build() + ); + } + + + + @Test + void testGetConcertRecommendations_Success() { + + // Given + String mockJsonResponse = """ + { + "recommendations": [ + [1, 0.9], + [2, 0.7] + ] + } + """; + + // RestTemplate Mock 설정 + try (MockedConstruction mocked = mockConstruction(RestTemplate.class, (mock, context) -> { + when(mock.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(mockJsonResponse)); + })) { + // When + List concertIds = recommendationService.parseConcertIdsFromJson(mockJsonResponse); + List recommendations = recommendationService.getConcertDetails(concertIds); + + // Then + assertThat(recommendations).isNotNull(); + assertThat(recommendations).hasSize(2); + assertThat(recommendations.get(0).getId()).isEqualTo(1L); + assertThat(recommendations.get(1).getId()).isEqualTo(2L); + } + } + + @Test + void testGetLikedConcertRecommendations_WithLikedConcert() { + // Given: The user has liked a concert + when(concertLikeRepository.findMostRecentLikedConcert(eq(1L), any(Pageable.class))) + .thenReturn(new PageImpl<>(Collections.singletonList(1L))); + + when(concertCategoryRepository.findCategoryNamesByConcertId(1L)) + .thenReturn(Arrays.asList("Category1", "Category2", "Category3")); + + when(concertRepository.findConcertById(1L)).thenReturn( + Concert.builder() + .id(1L) + .prfnm("Classical Concert") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .genrenm("Classical") + .build() + ); + + String mockJsonResponse = """ + { + "recommendations": [[1, 0.9], [2, 0.8]] + } + """; + // When + Pageable pageable = PageRequest.of(0, 1); + Long concertId = concertLikeRepository.findMostRecentLikedConcert(1L, pageable) + .getContent().stream().findFirst().orElse(null); + List concertIds = recommendationService.parseConcertIdsFromJson(mockJsonResponse); + List recommendedConcerts = recommendationService.getConcertDetails(concertIds); + List keywords = concertCategoryRepository.findCategoryNamesByConcertId(concertId); + // When + RecommendationConcertResponseV3 response = + RecommendationConcertResponseV3.builder() + .likedHistory(true) + .keywords(keywords) + .recommendationConcertsResponseV1s(recommendedConcerts) + .build(); + + // Then + assertThat(response).isNotNull(); + assertThat(response.getLikedHistory()).isTrue(); + assertThat(response.getKeywords()).containsExactly("Category1", "Category2", "Category3"); + assertThat(response.getRecommendationConcertsResponseV1s()).hasSize(2); + assertThat(response.getRecommendationConcertsResponseV1s().get(0).getId()).isEqualTo(1L); + assertThat(response.getRecommendationConcertsResponseV1s().get(1).getId()).isEqualTo(2L); + } + + @Test + void testGetLikedConcertRecommendations_NoLikedConcert() { + // Given + when(concertLikeRepository.findTopConcertIdsByLikeCount(any(Pageable.class))) + .thenReturn(Arrays.asList(1L, 2L)); + + when(concertCategoryRepository.findCategoryNamesByConcertId(1L)) + .thenReturn(Arrays.asList("TopCategory1", "TopCategory2")); + + when(concertRepository.findConcertById(1L)).thenReturn( + Concert.builder() + .id(1L) + .prfnm("Classical Concert") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .genrenm("Classical") + .build() + ); + + when(concertRepository.findConcertById(2L)).thenReturn( + Concert.builder() + .id(2L) + .prfnm("Pop Concert") + .prfpdfrom(LocalDate.of(2024, 2, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .genrenm("Pop") + .build() + ); + + // When + Pageable pageable = PageRequest.of(0, 3); + List topConcertIds = concertLikeRepository.findTopConcertIdsByLikeCount(pageable); + List recommendedConcerts = recommendationService.getConcertDetails(topConcertIds); + List keywords = concertCategoryRepository.findCategoryNamesByConcertId(1L); + + RecommendationConcertResponseV3 response = + RecommendationConcertResponseV3.builder() + .likedHistory(false) + .keywords(keywords) + .recommendationConcertsResponseV1s(recommendedConcerts) + .build(); + + // Then + assertThat(response).isNotNull(); + assertThat(response.getLikedHistory()).isFalse(); + assertThat(response.getKeywords()).containsExactly("TopCategory1", "TopCategory2"); + assertThat(response.getRecommendationConcertsResponseV1s()).hasSize(2); + assertThat(response.getRecommendationConcertsResponseV1s().get(0).getId()).isEqualTo(1L); + assertThat(response.getRecommendationConcertsResponseV1s().get(1).getId()).isEqualTo(2L); + } + + @Test + void testGetSearchedConcertRecommendations_Success() { + // Given + String mockJsonResponse = """ + { + "recommendations": [[1, 0.9], [2, 0.8], [3, 0.7]] + } + """; + + when(concertRepository.findConcertById(1L)).thenReturn( + Concert.builder() + .id(1L) + .prfnm("Classical Concert") + .prfpdfrom(LocalDate.of(2024, 1, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .genrenm("Classical") + .build() + ); + + when(concertRepository.findConcertById(2L)).thenReturn( + Concert.builder() + .id(2L) + .prfnm("Pop Concert") + .prfpdfrom(LocalDate.of(2024, 2, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .genrenm("Pop") + .build() + ); + + when(concertRepository.findConcertById(3L)).thenReturn( + Concert.builder() + .id(3L) + .prfnm("Jazz Concert") + .prfpdfrom(LocalDate.of(2024, 3, 1)) + .prfpdto(LocalDate.of(2024, 12, 31)) + .genrenm("Jazz") + .build() + ); + + // When + List concertIds = recommendationService.parseConcertIdsFromJson(mockJsonResponse); + List recommendations = recommendationService.getConcertDetails(concertIds); + + // Then + assertThat(recommendations).isNotNull(); + assertThat(recommendations).hasSize(3); + assertThat(recommendations.get(0).getId()).isEqualTo(1L); + assertThat(recommendations.get(1).getId()).isEqualTo(2L); + assertThat(recommendations.get(2).getId()).isEqualTo(3L); + } + + /* + @Test + void testGetConcertsFromFlaskSuccess() { + // Given + String FLASK_API_URL = "http://43.203.228.177:5000/recommendations/users/"; + Long id = 1L; + int topn = 3; + String expectedResponse = "{\"recommendations\":[[\"101\"],[\"102\"],[\"103\"]]}"; + + // Mock RestTemplate 동작 + String fullUrl = FLASK_API_URL + id + "/" + topn; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity mockResponse = ResponseEntity.ok(expectedResponse); + lenient().when(restTemplate.exchange(eq(fullUrl), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(mockResponse); + + // When + String actualResponse = recommendationService.getConcertsFromFlask(id, topn, FLASK_API_URL); + + // Then + assertThat(actualResponse).isNotNull(); + } + + */ + + /* + @Test + void testGetConcertsFromFlaskV2Success() { + // Given + String FLASK_API_URL = "http://43.203.228.177:5000/recommendations/clacobooks/"; + Long id = 1L; + String expectedResponse = "{\"recommendations\":[[\"201\"],[\"202\"]]}"; + + // Mock RestTemplate 동작 + String fullUrl = FLASK_API_URL + id; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity mockResponse = ResponseEntity.ok(expectedResponse); + lenient().when(restTemplate.exchange(eq(fullUrl), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(mockResponse); + + // When + String actualResponse = recommendationService.getConcertsFromFlaskV2(id, FLASK_API_URL); + + // Then + assertThat(actualResponse).isNotNull(); + } + + */ + + @Test + void testGetSearchedConcertRecommendations() { + // Given + Long concertId = 1L; + String FLASK_API_URL = "http://mock-api.com/recommendations/items/"; + String jsonResponse = "{\"recommendations\":[[\"101\"],[\"102\"],[\"103\"]]}"; + List concertIds = List.of(101L, 102L, 103L); + List mockRecommendations = List.of( + new RecommendationConcertsResponseV1(101L, "Mock Concert 101", "poster1.jpg", "Genre1", "Venue1", "2024-01-01", "2024-01-31"), + new RecommendationConcertsResponseV1(102L, "Mock Concert 102", "poster2.jpg", "Genre2", "Venue2", "2024-02-01", "2024-02-28"), + new RecommendationConcertsResponseV1(103L, "Mock Concert 103", "poster3.jpg", "Genre3", "Venue3", "2024-03-01", "2024-03-31") + ); + + // Mocking 내부 메서드 호출 + lenient().doReturn(jsonResponse).when(spyRecommendationService).getConcertsFromFlask(eq(concertId), eq(3), eq(FLASK_API_URL)); + lenient().doReturn(concertIds).when(spyRecommendationService).parseConcertIdsFromJson(eq(jsonResponse)); + lenient().doReturn(mockRecommendations).when(spyRecommendationService).getConcertDetails(eq(concertIds)); + + // When + List result = spyRecommendationService.getSearchedConcertRecommendations(concertId); + + // Then + assertThat(result).isNotNull(); + } + + @Test + void testGetConcertRecommendations() { + // Given + Long memberId = 1L; + String FLASK_API_URL = "http://mock-api.com/recommendations/users/"; + String jsonResponse = "{\"recommendations\":[[\"101\"],[\"102\"]]}"; + List concertIds = List.of(101L, 102L); + List mockRecommendations = List.of( + new RecommendationConcertsResponseV1(101L, "Mock Concert 101", "poster1.jpg", "Genre1", "Venue1", "2024-01-01", "2024-01-31"), + new RecommendationConcertsResponseV1(102L, "Mock Concert 102", "poster2.jpg", "Genre2", "Venue2", "2024-02-01", "2024-02-28") + ); + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + + // Mock 내부 메서드 호출 + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(memberId); + lenient().doReturn(jsonResponse).when(spyRecommendationService).getConcertsFromFlask(eq(memberId), eq(2), eq(FLASK_API_URL)); + lenient().doReturn(concertIds).when(spyRecommendationService).parseConcertIdsFromJson(eq(jsonResponse)); + lenient().doReturn(mockRecommendations).when(spyRecommendationService).getConcertDetails(eq(concertIds)); + + // When + List result = spyRecommendationService.getConcertRecommendations(5); + + // Then + assertThat(result).isNotNull(); + } + + @Test + void testGetLikedConcertRecommendations() { + // Given + Long memberId = 1L; + Long concertId = 101L; + String FLASK_API_URL = "http://mock-api.com/recommendations/items/"; + String jsonResponse = "{\"recommendations\":[[\"102\"],[\"103\"]]}"; + List concertIds = List.of(102L, 103L); + List keywords = List.of("Keyword1", "Keyword2"); + List mockRecommendations = List.of( + new RecommendationConcertsResponseV1(102L, "Mock Concert 102", "poster2.jpg", "Genre2", "Venue2", "2024-02-01", "2024-02-28"), + new RecommendationConcertsResponseV1(103L, "Mock Concert 103", "poster3.jpg", "Genre3", "Venue3", "2024-03-01", "2024-03-31") + ); + + // Mock 내부 메서드 호출 + JwtMemberDetail jwtMemberDetailMock = mock(JwtMemberDetail.class); + + + // Mock 내부 메서드 호출 + when(securityContextUtil.getContextMemberInfo()).thenReturn(jwtMemberDetailMock); + when(jwtMemberDetailMock.getMemberId()).thenReturn(memberId); + lenient().when(concertLikeRepository.findMostRecentLikedConcert(eq(memberId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(concertId))); + lenient().doReturn(jsonResponse).when(spyRecommendationService).getConcertsFromFlask(eq(concertId), eq(2), eq(FLASK_API_URL)); + lenient().doReturn(concertIds).when(spyRecommendationService).parseConcertIdsFromJson(eq(jsonResponse)); + lenient().doReturn(mockRecommendations).when(spyRecommendationService).getConcertDetails(eq(concertIds)); + lenient().when(concertCategoryRepository.findCategoryNamesByConcertId(eq(concertId))).thenReturn(keywords); + + // When + RecommendationConcertResponseV3 result = spyRecommendationService.getLikedConcertRecommendations(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getLikedHistory()).isTrue(); + assertThat(result.getKeywords()).isEqualTo(keywords); + } + +} + diff --git a/src/test/java/com/curateme/claco/review/service/PlaceCategoryServiceTest.java b/src/test/java/com/curateme/claco/review/service/PlaceCategoryServiceTest.java new file mode 100644 index 00000000..dbd888d5 --- /dev/null +++ b/src/test/java/com/curateme/claco/review/service/PlaceCategoryServiceTest.java @@ -0,0 +1,57 @@ +package com.curateme.claco.review.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.curateme.claco.review.domain.entity.PlaceCategory; +import com.curateme.claco.review.domain.vo.PlaceCategoryVO; +import com.curateme.claco.review.repository.PlaceCategoryRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class PlaceCategoryServiceTest { + + @Mock + PlaceCategoryRepository placeCategoryRepository; + @InjectMocks + PlaceCategoryServiceImpl placeCategoryService; + + @Test + @DisplayName("장소평 카테고리 가져오기") + void readPlaceCategoryList() { + // Given + PlaceCategory category1 = PlaceCategory.builder() + .id(1L) + .name("test1") + .build(); + + PlaceCategory category2 = PlaceCategory.builder() + .id(2L) + .name("test2") + .build(); + + when(placeCategoryRepository.findAll()).thenReturn(List.of(category1, category2)); + + // When + List assertResult = placeCategoryService.readPlaceCategoryList(); + + // Then + verify(placeCategoryRepository).findAll(); + + assertThat(assertResult.size()).isEqualTo(2); + + } + + +} \ No newline at end of file diff --git a/src/test/java/com/curateme/claco/review/service/TagCategoryServiceTest.java b/src/test/java/com/curateme/claco/review/service/TagCategoryServiceTest.java new file mode 100644 index 00000000..5b6554bf --- /dev/null +++ b/src/test/java/com/curateme/claco/review/service/TagCategoryServiceTest.java @@ -0,0 +1,64 @@ +package com.curateme.claco.review.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.curateme.claco.review.domain.entity.TagCategory; +import com.curateme.claco.review.domain.vo.TagCategoryVO; +import com.curateme.claco.review.repository.TagCategoryRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class TagCategoryServiceTest { + + @Mock + TagCategoryRepository tagCategoryRepository; + + @InjectMocks + TagCategoryServiceImpl tagCategoryService; + + final String test1 = "test1"; + final String test2 = "test2"; + + @Test + @DisplayName("감상평 카테고리 리스트 불러오기") + void readTagCategoryListTest() { + // Given + TagCategory category1 = TagCategory.builder() + .id(1L) + .name(test1) + .build(); + + TagCategory category2 = TagCategory.builder() + .id(2L) + .name(test2) + .build(); + + when(tagCategoryRepository.findAll()).thenReturn(List.of(category1, category2)); + + // When + List result = tagCategoryService.readTagCategoryList(); + + // Then + verify(tagCategoryRepository).findAll(); + + assertThat(result.stream().filter(tagCategoryVO -> + tagCategoryVO.getTagName().equals(test1) || tagCategoryVO.getTagName().equals(test2) + ) + .toList()) + .hasSize(2); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/curateme/claco/review/service/TicketReviewServiceTest.java b/src/test/java/com/curateme/claco/review/service/TicketReviewServiceTest.java new file mode 100644 index 00000000..447f4fa4 --- /dev/null +++ b/src/test/java/com/curateme/claco/review/service/TicketReviewServiceTest.java @@ -0,0 +1,491 @@ +package com.curateme.claco.review.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.awt.event.MouseMotionAdapter; +import java.io.IOException; +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.multipart.MultipartFile; + +import com.curateme.claco.authentication.domain.JwtMemberDetail; +import com.curateme.claco.authentication.util.SecurityContextUtil; +import com.curateme.claco.clacobook.domain.entity.ClacoBook; +import com.curateme.claco.clacobook.repository.ClacoBookRepository; +import com.curateme.claco.concert.domain.entity.Concert; +import com.curateme.claco.concert.repository.ConcertRepository; +import com.curateme.claco.global.entity.ActiveStatus; +import com.curateme.claco.global.util.S3Util; +import com.curateme.claco.member.domain.entity.Member; +import com.curateme.claco.member.domain.entity.Role; +import com.curateme.claco.member.repository.MemberRepository; +import com.curateme.claco.review.domain.dto.TicketReviewUpdateDto; +import com.curateme.claco.review.domain.dto.request.OrderBy; +import com.curateme.claco.review.domain.dto.request.TicketReviewCreateRequest; +import com.curateme.claco.review.domain.dto.response.ReviewInfoResponse; +import com.curateme.claco.review.domain.dto.response.ReviewListResponse; +import com.curateme.claco.review.domain.dto.response.TicketListResponse; +import com.curateme.claco.review.domain.dto.response.TicketReviewInfoResponse; +import com.curateme.claco.review.domain.entity.PlaceCategory; +import com.curateme.claco.review.domain.entity.PlaceReview; +import com.curateme.claco.review.domain.entity.ReviewImage; +import com.curateme.claco.review.domain.entity.ReviewTag; +import com.curateme.claco.review.domain.entity.TagCategory; +import com.curateme.claco.review.domain.entity.TicketReview; +import com.curateme.claco.review.domain.vo.ImageUrlVO; +import com.curateme.claco.review.domain.vo.PlaceCategoryVO; +import com.curateme.claco.review.domain.vo.TagCategoryVO; +import com.curateme.claco.review.repository.PlaceCategoryRepository; +import com.curateme.claco.review.repository.PlaceReviewRepository; +import com.curateme.claco.review.repository.ReviewImageRepository; +import com.curateme.claco.review.repository.ReviewTagRepository; +import com.curateme.claco.review.repository.TagCategoryRepository; +import com.curateme.claco.review.repository.TicketReviewRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class TicketReviewServiceTest { + + @Mock + TicketReviewRepository ticketReviewRepository; + @Mock + SecurityContextUtil securityContextUtil; + @Mock + MemberRepository memberRepository; + @Mock + ReviewImageRepository reviewImageRepository; + @Mock + PlaceCategoryRepository placeCategoryRepository; + @Mock + PlaceReviewRepository placeReviewRepository; + @Mock + TagCategoryRepository tagCategoryRepository; + @Mock + ReviewTagRepository reviewTagRepository; + @Mock + ConcertRepository concertRepository; + @Mock + ClacoBookRepository clacoBookRepository; + @Mock + S3Util s3Util; + @InjectMocks + TicketReviewServiceImpl ticketReviewService; + + @Test + @DisplayName("티켓&리뷰 생성") + void createTicketReview() throws IOException { + // Given + Long testId1 = 1L; + String testString = "test"; + JwtMemberDetail mockMemberDetail = mock(JwtMemberDetail.class); + Member mockMember = mock(Member.class); + Concert mockConcert = mock(Concert.class); + PlaceCategory mockPlaceCategory = mock(PlaceCategory.class); + TagCategory mockTagCategory = mock(TagCategory.class); + MultipartFile mockMultipartFile1 = mock(MultipartFile.class); + MultipartFile mockMultipartFile2 = mock(MultipartFile.class); + MultipartFile[] mockMultipartFiles = new MultipartFile[] {mockMultipartFile1, mockMultipartFile2}; + + TicketReviewCreateRequest request = TicketReviewCreateRequest.builder() + .placeReviewIds(List.of(PlaceCategoryVO.fromEntity(mockPlaceCategory))) + .tagCategoryIds(List.of(TagCategoryVO.fromEntity(mockTagCategory))) + .content(testString) + .concertId(testId1) + .watchDate(LocalDate.now()) + .watchRound(testString) + .starRate(BigDecimal.valueOf(3.5)) + .casting(testString) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(mockMemberDetail); + when(mockMemberDetail.getMemberId()).thenReturn(testId1); + when(memberRepository.findById(testId1)).thenReturn(Optional.of(mockMember)); + + when(clacoBookRepository.save(any(ClacoBook.class))).thenAnswer(invocation -> (ClacoBook) invocation.getArgument(0)); + when(concertRepository.findById(testId1)).thenReturn(Optional.of(mockConcert)); + + when(ticketReviewRepository.save(any(TicketReview.class))).thenAnswer(invocation -> { + TicketReview ticketReview = invocation.getArgument(0); + + Field createdAtField = TicketReview.class.getSuperclass().getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(ticketReview, LocalDateTime.now()); // 원하는 시간으로 설정 + + return ticketReview; + }); + + when(placeCategoryRepository.findById(anyLong())).thenReturn(Optional.of(mockPlaceCategory)); + when(placeReviewRepository.save(any(PlaceReview.class))).thenAnswer(invocation -> (PlaceReview) invocation.getArgument(0)); + + when(tagCategoryRepository.findById(anyLong())).thenReturn(Optional.of(mockTagCategory)); + when(reviewTagRepository.save(any(ReviewTag.class))).thenAnswer(invocation -> (ReviewTag) invocation.getArgument(0)); + + when(s3Util.uploadImage(any(MultipartFile.class), any(String.class))).thenAnswer( + invocationOnMock -> invocationOnMock.getArgument(1)); + when(reviewImageRepository.save(any(ReviewImage.class))).thenAnswer(invocation -> (ReviewImage) invocation.getArgument(0)); + + // When + TicketReviewInfoResponse result = ticketReviewService.createTicketReview(request, mockMultipartFiles); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(mockMemberDetail).getMemberId(); + verify(memberRepository).findById(testId1); + verify(clacoBookRepository).save(any(ClacoBook.class)); + verify(concertRepository).findById(testId1); + verify(ticketReviewRepository).save(any(TicketReview.class)); + verify(placeCategoryRepository).findById(anyLong()); + verify(placeReviewRepository).save(any(PlaceReview.class)); + verify(tagCategoryRepository).findById(anyLong()); + verify(reviewTagRepository).save(any(ReviewTag.class)); + verify(s3Util, times(2)).uploadImage(any(MultipartFile.class), any(String.class)); + verify(reviewImageRepository, times(2)).save(any(ReviewImage.class)); + + assertThat(result.getContent()).isEqualTo(testString); + assertThat(result.getImageUrlS()).hasSize(2); + + } + + @Test + @DisplayName("티켓 이미지 업데이트(생성)") + void addNewTicket() throws IOException { + // Given + Long testId1 = 1L; + String testString = "test"; + JwtMemberDetail mockMemberDetail = mock(JwtMemberDetail.class); + Member mockMember = mock(Member.class); + MultipartFile mockMultipartFile = mock(MultipartFile.class); + + TicketReview testTicketReview = TicketReview.builder() + .id(testId1) + .member(mockMember) + .watchRound(testString) + .watchDate(LocalDate.now()) + .starRate(BigDecimal.valueOf(3.5)) + .content(testString) + .casting(testString) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(mockMemberDetail); + when(mockMemberDetail.getMemberId()).thenReturn(testId1); + when(memberRepository.findById(testId1)).thenReturn(Optional.of(mockMember)); + when(ticketReviewRepository.findById(testId1)).thenReturn(Optional.of(testTicketReview)); + + when(s3Util.uploadImage(mockMultipartFile, "ticket-image/1")).thenReturn(testString); + + // When + ImageUrlVO result = ticketReviewService.addNewTicket(testId1, mockMultipartFile); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(mockMemberDetail).getMemberId(); + verify(memberRepository).findById(testId1); + verify(ticketReviewRepository).findById(testId1); + verify(s3Util).uploadImage(mockMultipartFile, "ticket-image/1"); + + assertThat(result.getImageUrl()).isEqualTo(testString); + + } + + @Test + @DisplayName("리뷰 상세 조회") + void readReview() { + // Given + Long testId1 = 1L; + String testString = "test"; + Member mockMember = mock(Member.class); + TicketReview testTicketReview = TicketReview.builder() + .id(testId1) + .member(mockMember) + .watchRound(testString) + .watchDate(LocalDate.now()) + .starRate(BigDecimal.valueOf(3.5)) + .content(testString) + .casting(testString) + .build(); + when(ticketReviewRepository.findTicketReviewById(testId1)).thenAnswer(invocation -> { + Field createdAtField = TicketReview.class.getSuperclass().getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(testTicketReview, LocalDateTime.now()); // 원하는 시간으로 설정 + return Optional.of(testTicketReview); + }); + + // When + ReviewInfoResponse result = ticketReviewService.readReview(testId1); + + // Then + verify(ticketReviewRepository).findTicketReviewById(testId1); + + assertThat(result.getContent()).isEqualTo(testString); + assertThat(result.getTicketReviewId()).isEqualTo(testId1); + + } + + @Test + @DisplayName("티켓&리뷰 정보 상세 조회") + void readTicketReview() { + // Given + Long testId1 = 1L; + String testString = "test"; + JwtMemberDetail mockMemberDetail = mock(JwtMemberDetail.class); + Member mockMember = mock(Member.class); + Concert mockConcert = mock(Concert.class); + TicketReview testTicketReview = TicketReview.builder() + .id(testId1) + .member(mockMember) + .watchRound(testString) + .watchDate(LocalDate.now()) + .starRate(BigDecimal.valueOf(3.5)) + .content(testString) + .casting(testString) + .concert(mockConcert) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(mockMemberDetail); + when(mockMemberDetail.getMemberId()).thenReturn(testId1); + when(memberRepository.findById(testId1)).thenReturn(Optional.of(mockMember)); + + when(ticketReviewRepository.findTicketReviewByIdIs(testId1)).thenAnswer(invocation -> { + Field createdAtField = TicketReview.class.getSuperclass().getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(testTicketReview, LocalDateTime.now()); // 원하는 시간으로 설정 + return Optional.of(testTicketReview); + }); + + // When + TicketReviewInfoResponse result = ticketReviewService.readTicketReview(testId1); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(mockMemberDetail).getMemberId(); + verify(memberRepository).findById(testId1); + + assertThat(result.getTicketReviewId()).isEqualTo(testId1); + assertThat(result.getContent()).isEqualTo(testString); + assertThat(result.getEditor()).isTrue(); + + } + + @Test + @DisplayName("콘서트의 리뷰 리스트 조회(페이징)") + void readReviewOfConcert() { + // Given + Long testId1 = 1L; + Concert mockConcert = mock(Concert.class); + + Page mockPage = mock(Page.class); + + when(concertRepository.findById(testId1)).thenReturn(Optional.of(mockConcert)); + when(ticketReviewRepository.findAllByConcertOrderByIdDesc(any(Concert.class), any(Pageable.class))) + .thenReturn(mockPage); + when(mockPage.getSize()).thenReturn(5); + + // When + ReviewListResponse result1 = ticketReviewService.readReviewOfConcert(testId1, 0, 5, OrderBy.RECENT); + + // Then + verify(concertRepository).findById(testId1); + verify(ticketReviewRepository).findAllByConcertOrderByIdDesc(any(Concert.class), any(Pageable.class)); + + assertThat(result1.getSize()).isEqualTo(5); + + } + + @Test + @DisplayName("클라코북의 티켓 리스트 조회") + void readTicketOfClacoBook() { + // Given + Long testId1 = 1L; + String testString = "test"; + TicketReview mockTicketReview = mock(TicketReview.class); + ClacoBook mockClacoBook = mock(ClacoBook.class); + + when(mockTicketReview.getTicketImage()).thenReturn(testString); + when(mockTicketReview.getId()).thenReturn(testId1); + when(clacoBookRepository.findById(testId1)).thenReturn(Optional.of(mockClacoBook)); + when(ticketReviewRepository.findByClacoBook(mockClacoBook)).thenReturn( + List.of(mockTicketReview, mockTicketReview)); + // When + TicketListResponse result = ticketReviewService.readTicketOfClacoBook(testId1); + + // Then + verify(mockTicketReview, times(2)).getTicketImage(); + verify(mockTicketReview, times(2)).getId(); + verify(clacoBookRepository).findById(testId1); + verify(ticketReviewRepository).findByClacoBook(mockClacoBook); + + assertThat(result.getTicketList()).hasSize(2); + + } + + @Test + @DisplayName("리뷰 개수 카운팅") + void countReview() { + // Given + Long testId1 = 1L; + String testString = "test"; + Concert mockConcert = mock(Concert.class); + + when(concertRepository.findById(testId1)).thenReturn(Optional.of(mockConcert)); + when(ticketReviewRepository.countTicketReviewByConcert(mockConcert)).thenReturn(2); + // When + Integer result = ticketReviewService.countReview(testId1); + + // Then + verify(concertRepository).findById(testId1); + verify(ticketReviewRepository).countTicketReviewByConcert(mockConcert); + + assertThat(result).isEqualTo(2); + + } + + @Test + @DisplayName("티켓&리뷰 수정(좌석, 별점, 감상평)") + void editTicketReview() { + // Given + Long testId1 = 1L; + String testString = "test"; + String beforeString = "hi"; + BigDecimal testBigDecimal = BigDecimal.valueOf(0.0); + JwtMemberDetail mockMemberDetail = mock(JwtMemberDetail.class); + Member mockMember = mock(Member.class); + + TicketReviewUpdateDto request = TicketReviewUpdateDto.builder() + .ticketReviewId(testId1) + .watchSit(testString) + .starRate(testBigDecimal) + .content(testString) + .build(); + + TicketReview testTicketReview = TicketReview.builder() + .id(testId1) + .member(mockMember) + .watchRound(beforeString) + .watchDate(LocalDate.now()) + .starRate(BigDecimal.valueOf(3.5)) + .content(beforeString) + .casting(beforeString) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(mockMemberDetail); + when(mockMemberDetail.getMemberId()).thenReturn(testId1); + when(memberRepository.findById(testId1)).thenReturn(Optional.of(mockMember)); + when(ticketReviewRepository.findById(testId1)).thenReturn(Optional.of(testTicketReview)); + + // When + TicketReviewUpdateDto result = ticketReviewService.editTicketReview(request); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(mockMemberDetail).getMemberId(); + verify(memberRepository).findById(testId1); + verify(ticketReviewRepository).findById(testId1); + + assertThat(result.getContent()).isEqualTo(testString); + assertThat(result.getStarRate()).isEqualTo(testBigDecimal); + assertThat(result.getWatchSit()).isEqualTo(testString); + + } + + @Test + @DisplayName("티켓 삭제") + void deleteTicket() { + // Given + Long testId1 = 1L; + String testString = "test"; + JwtMemberDetail mockMemberDetail = mock(JwtMemberDetail.class); + Member mockMember = mock(Member.class); + TicketReview testTicketReview = TicketReview.builder() + .id(testId1) + .member(mockMember) + .watchRound(testString) + .watchDate(LocalDate.now()) + .starRate(BigDecimal.valueOf(3.5)) + .content(testString) + .casting(testString) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(mockMemberDetail); + when(mockMemberDetail.getMemberId()).thenReturn(testId1); + when(memberRepository.findById(testId1)).thenReturn(Optional.of(mockMember)); + when(ticketReviewRepository.findById(testId1)).thenReturn(Optional.of(testTicketReview)); + doAnswer(invocationOnMock -> { + TicketReview ticketReview = invocationOnMock.getArgument(0); + ticketReview.updateActiveStatus(ActiveStatus.DELETED); + return ticketReview; + }).when(ticketReviewRepository).delete(testTicketReview); + + // When + ticketReviewService.deleteTicket(testId1); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(mockMemberDetail).getMemberId(); + verify(memberRepository).findById(testId1); + verify(ticketReviewRepository).findById(testId1); + + assertThat(testTicketReview.getActiveStatus()).isEqualTo(ActiveStatus.DELETED); + + } + + @Test + @DisplayName("티켓 리뷰 이동") + public void moveTicketReview() { + // Given + Long testId1 = 1L; + String testString = "test"; + JwtMemberDetail mockMemberDetail = mock(JwtMemberDetail.class); + Member mockMember = mock(Member.class); + ClacoBook mockClacoBook = mock(ClacoBook.class); + ClacoBook resultClacoBook = mock(ClacoBook.class); + TicketReview testTicketReview = TicketReview.builder() + .id(testId1) + .member(mockMember) + .clacoBook(mockClacoBook) + .watchRound(testString) + .watchDate(LocalDate.now()) + .starRate(BigDecimal.valueOf(3.5)) + .content(testString) + .casting(testString) + .build(); + + when(securityContextUtil.getContextMemberInfo()).thenReturn(mockMemberDetail); + when(mockMemberDetail.getMemberId()).thenReturn(testId1); + when(memberRepository.findById(testId1)).thenReturn(Optional.of(mockMember)); + when(ticketReviewRepository.findById(testId1)).thenReturn(Optional.of(testTicketReview)); + when(clacoBookRepository.findById(testId1)).thenReturn(Optional.of(resultClacoBook)); + + // When + ticketReviewService.moveTicketReview(testId1, testId1); + + // Then + verify(securityContextUtil).getContextMemberInfo(); + verify(mockMemberDetail).getMemberId(); + verify(memberRepository).findById(testId1); + verify(ticketReviewRepository).findById(testId1); + verify(clacoBookRepository).findById(testId1); + + assertThat(testTicketReview.getClacoBook()).isEqualTo(resultClacoBook); + + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..f3731486 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,19 @@ +spring: + datasource: + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + url: jdbc:tc:mysql:8.0://localhost:3306/test + username: root + password: password + + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + generate-ddl: true +front: + url: ${FRONT_URL} +backend: + domain: ${BACKEND_DOMAIN} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..a22088c7 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,9 @@ +spring: + application: + name: claco + profiles: + active: + - test + include: + - oauth + - aws