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```
+
+
+- summary
+ - statement coverage 기준: 88%
+ - branch coverage: 54.8%
+ - class coverage: 100%
+ - method coverage: 96.7%
+
+### 💫 부하 테스트 결과
+- 사용 인스턴스 유형: ```t2.large (ram 8GB)```
+- 부하 테스트 측정 툴: ```Jmeter```
+
+
+- summary
+ - 도메인별 주요 api 평균 50.3 Throughput
+
+## 🏛️ 아키텍처
+
+
+### 보안 고려 사항
+- 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
+
+
+## 📁 ERD
+
+
+- 카테고리에서 연관 관계 설정을 통해 관계형 데이터베이스 활용
+- AI 서비스 학습을 위한 soft delete 활용
+
+## 🛠️ CI/CD pipeline
+
+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 extends GrantedAuthority> 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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