diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bb89c4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.gradle +build +out +**/node_modules +**/.idea +**/*.iml +**/.DS_Store \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-issue.md b/.github/ISSUE_TEMPLATE/bug-issue.md new file mode 100644 index 0000000..aa9e088 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-issue.md @@ -0,0 +1,25 @@ +--- +name: BUG ISSUE +about: 버그 관련 이슈 +title: "[BUG] " +labels: "\U0001F41E Bug" +assignees: '' + +--- + +## 🐛 Description +> 버그에 대해 간단하게 설명해주세요. + + + +## ✏️ Bug Occur +> Given-When-Then 형식으로 서술해주세요. + + + +## 👀 Expected Result +> 예상했던 정상 결과에 대해 설명해주세요. + + + +## 📢 Notes diff --git a/.github/ISSUE_TEMPLATE/feature-issue.md b/.github/ISSUE_TEMPLATE/feature-issue.md new file mode 100644 index 0000000..d4daa2e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-issue.md @@ -0,0 +1,20 @@ +--- +name: FEATURE ISSUE +about: 기능 관련 이슈 +title: "[FEATURE]" +labels: "✨ Feature" +assignees: '' + +--- + +## 🚀 Description +> 작업 내용에 대해 간단하게 설명해주세요. + + + +## ✅ TODO +- [ ] todo +- [ ] todo + + +## 📢 Notes diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3852adc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## 📌 Related Issue +> #이슈번호 + + +## 🚀 Description +> 작업 내용에 대해 간단하게 설명해주세요. + + +## 📸 Screenshot + + +## 📢 Notes +> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요. \ No newline at end of file diff --git a/.github/workflows/release-deploy.yml b/.github/workflows/release-deploy.yml new file mode 100644 index 0000000..761ffc8 --- /dev/null +++ b/.github/workflows/release-deploy.yml @@ -0,0 +1,87 @@ +name: Release Deploy + +on: + push: + branches: ["release"] + workflow_dispatch: {} + +env: + AWS_REGION: us-west-2 + ACCOUNT_ID: 590184104064 + ECR_REPOSITORY: popcong-server + REGISTRY: ${{ env.ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com + IMAGE_TAG: ${{ github.sha }} + +concurrency: + group: release-deploy + cancel-in-progress: true + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build & Push (linux/amd64) + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64 + push: true + context: . + file: ./Dockerfile + tags: | + ${{ env.REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} + ${{ env.REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Connect & Deploy on EC2 + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + set -euo pipefail + + AWS_REGION="${{ env.AWS_REGION }}" + ACCOUNT_ID="${{ env.ACCOUNT_ID }}" + REGISTRY="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + + echo "[EC2] ECR 로그인" + aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$REGISTRY" + + echo "[EC2] 디렉토리 이동" + cd ~/popcong + + echo "[EC2] 최신 이미지 Pull" + docker compose pull + + echo "[EC2] 재기동" + docker compose up -d --remove-orphans + + echo "[EC2] 상태 확인" + docker compose ps + docker ps \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13b5f20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +src/main/generated/ +build/generated/ + +### 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/ + +### apple ### +.DS_Store +._.DS_Store +**/.DS_Store +**/._.DS_Store + +### yml ### +src/main/resources/application-local.yml +src/main/resources/application-prod.yml +src/main/resources/application-secret.yml + +### GCP ### +src/main/resources/popcong-c9addd5f509d.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..68e9327 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# amazoncorretto:21-alpine 이미지를 베이스로 사용 / 컨테이너 내부 작업 디렉토리는 app으로 함 +FROM amazoncorretto:21-alpine AS builder +WORKDIR /app + +# Gradle 캐시 최적화를 위한 복사 +COPY gradlew gradle.properties* settings.gradle* build.gradle* /app/ +COPY gradle /app/gradle + +# Gradle Wrapper 실행 권한 부여 및 버전 확인 +RUN chmod +x gradlew && ./gradlew --version + +# 실제 소스코드 복사 +COPY . /app + +# 이미지 빌드 +RUN ./gradlew clean bootJar -x test + +# Spring Boot layertools로 레이어 추출 +RUN java -Djarmode=layertools -jar $(find build/libs -name "*.jar" | head -n 1) extract --destination /app/layers + +# Runtime +# 런타임에서도 같은 openjdk 이미지 사용 +FROM amazoncorretto:21-alpine + +# 일반 사용자로 실행 +RUN addgroup -S popcong && adduser -S popcong -G popcong -u 1001 + +# 런타임 작업 디렉토리 지정 +WORKDIR /app + +# Spring Boot layertools 사용한 경우 설정하는 부분 +# 의존성 레이어 +COPY --from=builder /app/layers/dependencies/ ./ +# Spring Boot 런처 레이어 +COPY --from=builder /app/layers/spring-boot-loader/ ./ +# 스냅샷 의존성 레이어 +COPY --from=builder /app/layers/snapshot-dependencies/ ./ +# 애플리케이션 코드 레이어: 가장 자주 바뀌는 부분 +COPY --from=builder /app/layers/application/ ./ + +# 한국 환경 기본 환경변수 설정 +ENV TZ=Asia/Seoul \ + LANG=ko_KR.UTF-8 \ + LANGUAGE=ko_KR:ko:en \ + LC_ALL=ko_KR.UTF-8 \ + SPRING_PROFILES_ACTIVE=prod \ + SERVER_PORT=8080 \ + JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50 -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul" + +# 컨테이너에서 노출할 포트 +EXPOSE 8080 + +# root가 아닌 spring 사용자로 실행 +USER popcong + +# JarLauncher로 레이어 디렉토리 구조에 맞춰 앱 부팅 (Spring Boot layertools 사용한 경우) +ENTRYPOINT ["sh","-c","java $JAVA_OPTS -cp /app org.springframework.boot.loader.launch.JarLauncher"] \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..4d00e7c --- /dev/null +++ b/build.gradle @@ -0,0 +1,109 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.4' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'popcong' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + // Dev + developmentOnly 'org.springframework.boot:spring-boot-devtools' + annotationProcessor 'org.projectlombok:lombok' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + // Web + implementation 'org.springframework.boot:spring-boot-starter-web' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // OAuth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // Database + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api:2.1.1' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // HealthCheck + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Springdoc + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + // ModelMapper + implementation 'org.modelmapper:modelmapper:2.4.2' + + // Validation + implementation 'jakarta.validation:jakarta.validation-api:3.1.0' + + //AWS + implementation platform('com.amazonaws:aws-java-sdk-bom:1.12.700') + implementation 'com.amazonaws:aws-java-sdk-s3' // S3 + implementation 'com.amazonaws:aws-java-sdk-sts' + + // GCP (Google Drive) + implementation 'com.google.guava:guava:32.1.0-jre' + implementation 'com.google.http-client:google-http-client:1.41.8' + implementation 'com.google.http-client:google-http-client-jackson2:1.41.8' + implementation 'com.google.api-client:google-api-client:2.7.2' // Google API Client + implementation 'com.google.apis:google-api-services-drive:v3-rev20250511-2.0.0' // Google Drive + implementation 'com.google.auth:google-auth-library-oauth2-http:1.20.0' // Google Auth +} + +// Q타입 엔티티가 생성될 경로 +def generated = 'src/main/generated' + + +// compile시 Q타입 경로에 파일 생성 +tasks.withType(JavaCompile).configureEach { + options.generatedSourceOutputDirectory.set(file(generated)) +} + +sourceSets { + main.java.srcDirs += [ generated ] +} + +// clean 시 Q타입 삭제 +clean { + delete file(generated) +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 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 0000000..d4081da --- /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.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/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\n' "$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="\\\"\\\"" + + +# 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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 0000000..5eed7ee --- /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= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +: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/settings.gradle b/settings.gradle new file mode 100644 index 0000000..0cdaa80 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'app' diff --git a/src/main/java/popcong/app/AppApplication.java b/src/main/java/popcong/app/AppApplication.java new file mode 100644 index 0000000..18ac09e --- /dev/null +++ b/src/main/java/popcong/app/AppApplication.java @@ -0,0 +1,16 @@ +package popcong.app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import popcong.app.infra.config.security.SecurityProperties; + +@SpringBootApplication +@EnableConfigurationProperties(SecurityProperties.class) +public class AppApplication { + + public static void main(String[] args) { + SpringApplication.run(AppApplication.class, args); + } + +} diff --git a/src/main/java/popcong/app/adapter/in/image/ImageController.java b/src/main/java/popcong/app/adapter/in/image/ImageController.java new file mode 100644 index 0000000..cdab709 --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/image/ImageController.java @@ -0,0 +1,42 @@ +package popcong.app.adapter.in.image; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import popcong.app.adapter.out.persistence.image.entity.ImageJpaEntity; +import popcong.app.application.image.service.ImageService; +import popcong.app.domain.image.model.ImageableType; +import popcong.app.global.dto.ResponseDto; + +import java.util.List; +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/images") +public class ImageController { + private final ImageService imageService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseDto upload( + @RequestParam("imageableId") Long imageableId, + @RequestParam("imageableType") ImageableType imageableType, + @RequestParam("saveOrder") Integer saveOrder, + @RequestPart("file") MultipartFile file + ) { + ImageJpaEntity saved = imageService.upload(imageableType,imageableId,saveOrder, file); + return new ResponseDto<>(HttpStatus.CREATED.value(), "이미지 업로드 성공", saved); + } + + @GetMapping + public ResponseDto> list( + @RequestParam("imageableId") Long imageableId, + @RequestParam("imageableType") ImageableType imageableType + ) { + List images = imageService.list(imageableId, imageableType); + return new ResponseDto<>(HttpStatus.OK.value(), "이미지 목록 조회 성공", images); + } + +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/in/jwt/CustomOAuth2UserService.java b/src/main/java/popcong/app/adapter/in/jwt/CustomOAuth2UserService.java new file mode 100644 index 0000000..b5cb54e --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/jwt/CustomOAuth2UserService.java @@ -0,0 +1,49 @@ +package popcong.app.adapter.in.jwt; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import popcong.app.adapter.out.oauth.OAuth2UserInfoFactory; +import popcong.app.domain.auth.model.OAuth2SignInCommand; +import popcong.app.application.auth.port.in.OAuth2SignInUseCase; +import popcong.app.application.auth.port.out.OAuth2UserInfoPort; +import popcong.app.domain.user.model.User; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final OAuth2SignInUseCase oAuth2SignInUseCase; + + // 카카오 응답 시 User 매핑 + @Override + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + log.debug("CustomOAuth2UserService loadUser"); + + OAuth2User oAuth2User = super.loadUser(request); + log.debug("kakao 응답 : {}", oAuth2User.getAttributes()); + + String registrationId = request.getClientRegistration().getRegistrationId(); + + // OAuth2UserInfoFactory로 사용자 객체 변환 + OAuth2UserInfoPort userInfo = OAuth2UserInfoFactory.of(registrationId, oAuth2User.getAttributes()); + + log.debug("email = {}, providerId = {}", userInfo.getEmail(), userInfo.getProviderId()); + + // command 객체로 변환 + OAuth2SignInCommand command = new OAuth2SignInCommand( + userInfo.getProvider(), + userInfo.getProviderId(), + userInfo.getEmail() + ); + + User user = oAuth2SignInUseCase.findOrCreateUser(command); + + return new CustomUserDetails(user, oAuth2User.getAttributes()); + } +} diff --git a/src/main/java/popcong/app/adapter/in/jwt/CustomUserDetails.java b/src/main/java/popcong/app/adapter/in/jwt/CustomUserDetails.java new file mode 100644 index 0000000..9a76da6 --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/jwt/CustomUserDetails.java @@ -0,0 +1,81 @@ +package popcong.app.adapter.in.jwt; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; +import popcong.app.domain.user.model.User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +public class CustomUserDetails implements UserDetails, OAuth2User { + + private final User user; + private Map attributes; + + public CustomUserDetails( + User user, + Map attributes + ) { + this.user = user; + this.attributes = attributes; + } + + + @Override + public Collection getAuthorities() { + return Collections.singleton(new SimpleGrantedAuthority(user.role().name())); + } + + /** + * UserDetails 인터페이스 구현 + */ + @Override + public String getUsername() { + return user.email(); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + /** + * OAuth2User 인터페이스 구현 + */ + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return user.name(); + } + + +} diff --git a/src/main/java/popcong/app/adapter/in/jwt/JwtFilter.java b/src/main/java/popcong/app/adapter/in/jwt/JwtFilter.java new file mode 100644 index 0000000..4225c28 --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/jwt/JwtFilter.java @@ -0,0 +1,70 @@ +package popcong.app.adapter.in.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import popcong.app.adapter.out.jwt.JwtUtils; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtils jwtUtils; + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_PREFIX = "Bearer "; + + private static final List WHITE_LIST = List.of( + "/oauth2/", + "/login/oauth2/", + "/swagger-ui/", + "/v3/api-docs/", + "/swagger-ui.html" + ); + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + return WHITE_LIST.stream().anyMatch(uri::startsWith); + } + + /** + * JWT 유효성 검증 및 인증 설정 + */ + @Override + protected void doFilterInternal( + @NotNull HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain + ) throws ServletException, IOException { + + String jwt = resolveToken(request); + + if (StringUtils.hasText(jwt) && jwtUtils.validateToken(jwt)) { +// Authentication authentication = jwtUtils.getAuthentication(jwt); + Authentication authentication = jwtUtils.authentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } + + // Bearer Token에서 JWT 추출 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/in/jwt/handler/AbstractSecurityFailureHandler.java b/src/main/java/popcong/app/adapter/in/jwt/handler/AbstractSecurityFailureHandler.java new file mode 100644 index 0000000..c723296 --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/jwt/handler/AbstractSecurityFailureHandler.java @@ -0,0 +1,28 @@ +package popcong.app.adapter.in.jwt.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import popcong.app.global.exception.ErrorResponse; + +import java.io.IOException; + +/** + * 보안 예외 응답 공통 처리 + */ +@RequiredArgsConstructor +public abstract class AbstractSecurityFailureHandler { + + private final ObjectMapper objectMapper; + + protected void writeErrorResponse( + HttpServletResponse response, + ErrorResponse errorResponse + ) throws IOException { + response.setStatus(errorResponse.getHttpStatus()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/src/main/java/popcong/app/adapter/in/jwt/handler/AuthenticationFailureHandler.java b/src/main/java/popcong/app/adapter/in/jwt/handler/AuthenticationFailureHandler.java new file mode 100644 index 0000000..829d4b5 --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/jwt/handler/AuthenticationFailureHandler.java @@ -0,0 +1,32 @@ +package popcong.app.adapter.in.jwt.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import popcong.app.global.exception.ErrorResponse; +import popcong.app.global.exception.error.AuthErrorCode; + +import java.io.IOException; + +/** + * 인증(Authentication) 실패 처리 (401) + */ +@Component +public class AuthenticationFailureHandler extends AbstractSecurityFailureHandler implements AuthenticationEntryPoint { + + public AuthenticationFailureHandler(ObjectMapper objectMapper) { + super(objectMapper); + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + writeErrorResponse(response, ErrorResponse.of(AuthErrorCode.UNAUTHORIZED)); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/in/jwt/handler/AuthorizationFailureHandler.java b/src/main/java/popcong/app/adapter/in/jwt/handler/AuthorizationFailureHandler.java new file mode 100644 index 0000000..67f02c3 --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/jwt/handler/AuthorizationFailureHandler.java @@ -0,0 +1,35 @@ +package popcong.app.adapter.in.jwt.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import popcong.app.global.exception.ErrorResponse; +import popcong.app.global.exception.error.AuthErrorCode; + +import java.io.IOException; + +/** + * 인가(Authorization) 실패 처리 (403) + */ +@Slf4j +@Component +public class AuthorizationFailureHandler extends AuthenticationFailureHandler implements AccessDeniedHandler { + + public AuthorizationFailureHandler(ObjectMapper objectMapper) { + super(objectMapper); + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + log.error("No Authorities", accessDeniedException); + writeErrorResponse(response, ErrorResponse.of(AuthErrorCode.ACCESS_DENIED)); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/in/jwt/handler/OAuth2SuccessHandler.java b/src/main/java/popcong/app/adapter/in/jwt/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..ea45c3e --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/jwt/handler/OAuth2SuccessHandler.java @@ -0,0 +1,163 @@ +package popcong.app.adapter.in.jwt.handler; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import popcong.app.adapter.in.jwt.CustomUserDetails; +import popcong.app.adapter.out.jwt.JwtUtils; +import popcong.app.domain.auth.model.AuthInfo; +import popcong.app.domain.user.model.User; +import popcong.app.domain.user.model.UserRole; +import popcong.app.infra.config.cors.CorsProperties; + +import java.io.IOException; +import java.time.Duration; +import java.util.Arrays; +import java.util.Optional; + +//import static popcong.app.infra.config.security.SignInRedirectCaptureFilter.ALLOW_LIST; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtUtils jwtUtils; + + private final CorsProperties corsProperties; + + @Value("${app.frontend.base-url}") + private String frontendBaseUrl; + + @Value("${app.frontend.path.main}") + private String mainPath; // home + @Value("${app.frontend.path.signup}") + private String signupPath; // signup + + @Value("${app.cookie.domain}") + private String cookieDomain; + @Value("${app.cookie.secure}") + private boolean cookieSecure; + @Value("${app.cookie.same-site}") + private String cookieSameSite; + @Value("${app.cookie.access.name}") + private String accessCookieName; + @Value("${app.cookie.access.path}") + private String accessCookiePath; + @Value("${app.cookie.access.max-age}") + private long accessCookieMaxAge; + @Value("${app.cookie.refresh.name}") + private String refreshCookieName; + @Value("${app.cookie.refresh.path}") + private String refreshCookiePath; + @Value("${app.cookie.refresh.max-age}") + private long refreshCookieMaxAge; + + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + + // 사용자 정보 불러오기 + CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal(); + User user = customUserDetails.getUser(); + + // 토큰 발급 + AuthInfo authInfo = AuthInfo.from(user); + String accessToken = jwtUtils.generateAccessToken(authInfo); + String refreshToken = jwtUtils.generateRefreshToken(authInfo); + + // 회원 여부 확인 + boolean isRegistered = !UserRole.GUEST.equals(user.userRole()); + + log.info("OAuth2 로그인 처리 완료 - email: {}, isRegistered: {}", user.email(), isRegistered); + log.info("accessToken: {}", accessToken); + log.info("refreshToken: {}", refreshToken); + + // 이름 모두 제거 + removeCookie(response, "access_token", "/"); + removeCookie(response, "refresh_token", "/"); + removeCookie(response, accessCookieName, accessCookiePath); + removeCookie(response, refreshCookieName, refreshCookiePath); + + // 현재 이름으로만 심기 + addCookie(response, accessCookieName, accessToken, accessCookiePath, accessCookieMaxAge); + addCookie(response, refreshCookieName, refreshToken, refreshCookiePath, refreshCookieMaxAge); + + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Pragma", "no-cache"); + + String desired = readCookie(request, "login_redirect") + .or(() -> Optional.ofNullable(request.getParameter("redirect_uri"))) + .or(() -> Optional.ofNullable(request.getHeader("Origin"))) + .orElse(null); + + String base = resolveAllowedBase(desired, frontendBaseUrl); + String redirectUrl = base + (isRegistered ? mainPath : signupPath); + + removeCookie(response, "login_redirect", "/"); + + log.info("리다이렉트 URL: {}", redirectUrl); + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + } + + private void addCookie( + HttpServletResponse response, + String name, + String value, + String path, + long maxAgeSeconds + ) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .domain(cookieDomain) + .path(path) + .maxAge(Duration.ofSeconds(maxAgeSeconds)) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + private void removeCookie(HttpServletResponse response, String name, String path) { + ResponseCookie cookie = ResponseCookie.from(name, "") + .httpOnly(true) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .domain(cookieDomain) + .path(path) + .maxAge(Duration.ZERO) // 삭제 + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + private Optional readCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return Optional.empty(); + return Arrays.stream(cookies) + .filter(c -> name.equals(c.getName())) + .map(Cookie::getValue) + .findFirst(); + } + + + private String resolveAllowedBase(String desired, String fallback) { + if (desired == null || desired.isBlank()) { + return fallback; + } + boolean allowed = corsProperties.getAllowedOrigins().stream().anyMatch(desired::startsWith); + return allowed ? desired : fallback; + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/in/space/SpaceController.java b/src/main/java/popcong/app/adapter/in/space/SpaceController.java new file mode 100644 index 0000000..2d68d9e --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/space/SpaceController.java @@ -0,0 +1,64 @@ +package popcong.app.adapter.in.space; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import popcong.app.adapter.out.persistence.space.mapper.SpaceMarkerMapper; +import popcong.app.application.space.dto.request.CurrentUserLocationRequestDto; +import popcong.app.application.space.dto.response.MarkerListResponseDto; +import popcong.app.application.space.port.in.SpaceMapQueryUseCase; +import popcong.app.domain.space.model.SpaceSortType; +import popcong.app.global.dto.ResponseDto; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.SpaceErrorCode; + + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/popup/space") +public class SpaceController { + + private final SpaceMapQueryUseCase spaceMapQueryUseCase; + private final SpaceMarkerMapper spaceMarkerMapper; + + @GetMapping("/markers") + public ResponseDto getMarkers( + @RequestBody CurrentUserLocationRequestDto request, + @RequestParam(name = "nw-lat") Double nwLat, + @RequestParam(name = "nw-lng") Double nwLng, + @RequestParam(name = "se-lat") Double seLat, + @RequestParam(name = "se-lng") Double seLng, + @RequestParam(name = "min-amount", required = false) Integer minAmount, + @RequestParam(name = "max-amount", required = false) Integer maxAmount, + @RequestParam(name = "floor", required = false) Integer floor, + @RequestParam(name = "rating", required = false) Double rating, + @RequestParam(name = "sort", defaultValue = "MOST_POPULAR") SpaceSortType sort + ){ + if (request == null) { + throw new BusinessException(SpaceErrorCode.USER_LOCATION_REQUIRED); + } + + if (minAmount != null && maxAmount != null && minAmount > maxAmount) { + throw new BusinessException(SpaceErrorCode.INVALID_PRICE_ERROR); + } + + Double userLat = (request != null) ? request.latitude() : null; + Double userLng = (request != null) ? request.longitude() : null; + + var markers = spaceMapQueryUseCase.getMarkerInDisplayWithFilters( + userLat, userLng, + nwLat, nwLng, seLat, seLng, + minAmount, maxAmount, floor, rating, sort + ); + + MarkerListResponseDto result = new MarkerListResponseDto(markers); + + return new ResponseDto<>( + HttpStatus.OK.value(), + "마커 조회 성공", + result + ); + } +} diff --git a/src/main/java/popcong/app/adapter/in/user/HomeController.java b/src/main/java/popcong/app/adapter/in/user/HomeController.java new file mode 100644 index 0000000..5210aef --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/user/HomeController.java @@ -0,0 +1,45 @@ +package popcong.app.adapter.in.user; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import popcong.app.application.user.dto.response.HomeUserResponseDto; +import popcong.app.application.user.port.in.GetHomeInfoUseCase; +import popcong.app.domain.user.model.User; +import popcong.app.global.dto.ResponseDto; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.AuthErrorCode; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/popup/home") +public class HomeController { + + private final GetHomeInfoUseCase getHomeInfoUseCase; + + @GetMapping("/me") + public ResponseDto getHomeInfo( + @AuthenticationPrincipal User user + ) { + if (user == null) { + throw new BusinessException(AuthErrorCode.UNAUTHORIZED); + } + + HomeUserResponseDto result = + getHomeInfoUseCase.getMyHomeInfo(user.userId()); + + log.info("홈 정보 조회 성공 : userId={}", user.userId()); + + return new ResponseDto<>( + HttpStatus.OK.value(), + "홈 정보 조회 성공", + result + ); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/in/user/UserController.java b/src/main/java/popcong/app/adapter/in/user/UserController.java new file mode 100644 index 0000000..c83b128 --- /dev/null +++ b/src/main/java/popcong/app/adapter/in/user/UserController.java @@ -0,0 +1,142 @@ +package popcong.app.adapter.in.user; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import popcong.app.application.auth.port.in.GoogleDriveSubmitUseCase; +import popcong.app.application.user.dto.response.UserResponseDto; +import popcong.app.application.user.port.in.UserInfoUseCase; +import popcong.app.domain.user.model.DocumentType; +import popcong.app.domain.user.model.SignUpUserType; +import popcong.app.domain.user.model.UploadItem; +import popcong.app.domain.user.model.User; +import popcong.app.global.dto.ResponseDto; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.AuthErrorCode; +import popcong.app.global.exception.error.CommonErrorCode; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/user") +public class UserController { + + private final GoogleDriveSubmitUseCase googleDriveSubmitUseCase; + private final UserInfoUseCase userInfoUseCase; + + + /** + * GUEST 파일 업로드 API + */ + @PostMapping(value = "/my/guest/upload-files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseDto uploadGuestFiles( + @AuthenticationPrincipal User user, + @RequestPart(value = "copyOfIdentification") MultipartFile copyOfIdentification, + @RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense + ) { + if (user == null) { + throw new BusinessException(AuthErrorCode.UNAUTHORIZED); + } + + if (copyOfIdentification.isEmpty()) { + throw new BusinessException(CommonErrorCode.NO_REQUIRED_FILES); + } + + // 리스트에 업로드 파일 추가 + List items = new ArrayList<>(); + items.add(new UploadItem(DocumentType.COPY_OF_IDENTIFICATION, copyOfIdentification)); + + if (businessLicense != null && !businessLicense.isEmpty()) { + items.add(new UploadItem(DocumentType.BUSINESS_LICENSE, businessLicense)); + } + + // 업로드 + List fileIds = googleDriveSubmitUseCase.submitUserDocs( + SignUpUserType.GUEST, + user.userId(), + user.email(), + items + ); + + userInfoUseCase.updateUserRoleToGeneral(user.userId()); + + return new ResponseDto<>( + HttpStatus.OK.value(), + "게스트 파일 업로드 완료", + null + ); + } + + + /** + * HOST 파일 업로드 API + */ + @PostMapping(value = "/my/host/upload-files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseDto uploadHostFiles( + @AuthenticationPrincipal User user, + @RequestPart(value = "copyOfIdentification") MultipartFile copyOfIdentification, + @RequestPart(value = "buildingRegister") MultipartFile buildingRegister, + @RequestPart(value = "leaseAgreement") MultipartFile leaseAgreement, + @RequestPart(value = "copyOfBankBook", required = false) MultipartFile copyOfBankBook + ) { + if (user == null) { + throw new BusinessException(AuthErrorCode.UNAUTHORIZED); + } + + if (copyOfIdentification.isEmpty() || buildingRegister == null || leaseAgreement == null) { + throw new BusinessException(CommonErrorCode.NO_REQUIRED_FILES); + } + + // 리스트에 업로드 파일 추가 + List items = new ArrayList<>(); + items.add(new UploadItem(DocumentType.COPY_OF_IDENTIFICATION, copyOfIdentification)); + items.add(new UploadItem(DocumentType.BUILDING_REGISTER, buildingRegister)); + items.add(new UploadItem(DocumentType.LEASE_AGREEMENT, leaseAgreement)); + + + if (copyOfBankBook != null && !copyOfBankBook.isEmpty()) { + items.add(new UploadItem(DocumentType.COPY_OF_BANKBOOK, copyOfBankBook)); + } + + // 업로드 + List fileIds = googleDriveSubmitUseCase.submitUserDocs( + SignUpUserType.HOST, + user.userId(), + user.email(), + items + ); + + userInfoUseCase.updateUserRoleToPending(user.userId()); + + return new ResponseDto<>( + HttpStatus.OK.value(), + "호스트 파일 업로드 완료", + null + ); + } + + + @GetMapping("/me") + public ResponseDto getUserInfo(@AuthenticationPrincipal User user) { + if (user == null) { + throw new BusinessException(AuthErrorCode.UNAUTHORIZED); + } + + log.info("사용자 조회 성공 : userId = {}, providerId = {}, email = {}", user.userId(), user.providerId(), user.email()); + + UserResponseDto result = UserResponseDto.from(user); + + return new ResponseDto<>( + HttpStatus.OK.value(), + "사용자 조회 성공", + result + ); + } +} diff --git a/src/main/java/popcong/app/adapter/out/jwt/JwtUtils.java b/src/main/java/popcong/app/adapter/out/jwt/JwtUtils.java new file mode 100644 index 0000000..ce90aaf --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/jwt/JwtUtils.java @@ -0,0 +1,114 @@ +package popcong.app.adapter.out.jwt; + +import io.jsonwebtoken.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; +import popcong.app.application.user.port.out.LoadUserPort; +import popcong.app.domain.auth.model.AuthInfo; +import popcong.app.application.auth.port.in.TokenGenerateUseCase; + +import javax.crypto.SecretKey; +import java.util.Collections; +import java.util.List; + +@Slf4j +@Component +public class JwtUtils { + + private final SecretKey secretKey; + public final TokenGenerateUseCase accessTokenGenerateService; + public final TokenGenerateUseCase refreshTokenGenerateService; + private final LoadUserPort loadUserPort; + + public JwtUtils( + SecretKey secretKey, + @Qualifier("accessTokenGenerateService") TokenGenerateUseCase accessTokenGenerateService, + @Qualifier("refreshTokenGenerateService") TokenGenerateUseCase refreshTokenGenerateService, LoadUserPort loadUserPort + ) { + this.secretKey = secretKey; + this.accessTokenGenerateService = accessTokenGenerateService; + this.refreshTokenGenerateService = refreshTokenGenerateService; + this.loadUserPort = loadUserPort; + } + + // Access Tokne 생성 + public String generateAccessToken(AuthInfo authInfo) { + return accessTokenGenerateService.generateToken(authInfo); + } + + // Refresh Token 생성 + public String generateRefreshToken(AuthInfo authInfo) { + return refreshTokenGenerateService.generateToken(authInfo); + } + + // Token 유효성 검증 + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + + return true; + } catch (SecurityException | MalformedJwtException e) { + log.info("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.info("만료된 JWT 서명입니다."); + } catch (UnsupportedJwtException e) { + log.info("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + log.info("JWT 토큰이 잘못되었습니다."); + } + + return false; + } + + // JWT Claims 추출 + private Claims getClaimsFromToken(String accessToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + // jwt로 사용자 추출 + public Authentication authentication(String accessToken) { + Claims claims = getClaimsFromToken(accessToken); + Long userId = Long.valueOf(claims.getSubject()); + String role = claims.get("role", String.class); + var authorities = List.of(new SimpleGrantedAuthority(role != null ? role : "ROLE_USER")); + + popcong.app.domain.user.model.User user = loadUserPort.loadUserById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다. : " + userId)); + + return new UsernamePasswordAuthenticationToken(user, accessToken, authorities); + } + + // Access Token에서 인증 정보 추출 + public Authentication getAuthentication(String accessToken) { + Claims claims = getClaimsFromToken(accessToken); + String role = claims.get("role", String.class); + + User principal = new User( + claims.getSubject(), + "", + Collections.singleton(new SimpleGrantedAuthority(role)) + ); + + return new UsernamePasswordAuthenticationToken( + principal, + accessToken, + principal.getAuthorities() + ); + } +} diff --git a/src/main/java/popcong/app/adapter/out/oauth/KakaoOAuth2UserInfo.java b/src/main/java/popcong/app/adapter/out/oauth/KakaoOAuth2UserInfo.java new file mode 100644 index 0000000..02ac373 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/oauth/KakaoOAuth2UserInfo.java @@ -0,0 +1,40 @@ +package popcong.app.adapter.out.oauth; + +import popcong.app.application.auth.port.out.OAuth2UserInfoPort; +import popcong.app.domain.user.model.Provider; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.AuthErrorCode; + +import java.util.Map; + +public class KakaoOAuth2UserInfo implements OAuth2UserInfoPort { + + private final Map attributes; + + public KakaoOAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public Provider getProvider() { + return Provider.kakao; + } + + @Override + public String getEmail() { + Object kakaoAccountObject = attributes.get("kakao_account"); + + if (!(kakaoAccountObject instanceof Map)) { + throw new BusinessException(AuthErrorCode.INVALID_KAKAO_RESPONSE); + } + + Map kakaoAccount = (Map) kakaoAccountObject; + + return (String) kakaoAccount.get("email"); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/out/oauth/OAuth2UserInfoFactory.java b/src/main/java/popcong/app/adapter/out/oauth/OAuth2UserInfoFactory.java new file mode 100644 index 0000000..c581a14 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/oauth/OAuth2UserInfoFactory.java @@ -0,0 +1,26 @@ +package popcong.app.adapter.out.oauth; + +import lombok.Getter; +import popcong.app.application.auth.port.out.OAuth2UserInfoPort; +import popcong.app.domain.user.model.Provider; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.AuthErrorCode; + +import java.util.Map; + +@Getter +public class OAuth2UserInfoFactory { + + public static OAuth2UserInfoPort of( + String registrationId, + Map attributes + ) { + if (Provider.kakao.toString().equals(registrationId)) { + return new KakaoOAuth2UserInfo(attributes); + } + + // google, naver, apple 확장 가능 + + throw new BusinessException(AuthErrorCode.UNSUPPORTED_SOCIAL_LOGIN); + } +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/chat/entity/ChatJpaEntity.java b/src/main/java/popcong/app/adapter/out/persistence/chat/entity/ChatJpaEntity.java new file mode 100644 index 0000000..173edd0 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/chat/entity/ChatJpaEntity.java @@ -0,0 +1,47 @@ +package popcong.app.adapter.out.persistence.chat.entity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import popcong.app.adapter.out.persistence.user.entity.UserJpaEntity; +import popcong.app.adapter.out.persistence.space.entity.SpaceJpaEntity; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "CHAT") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "chatId", nullable = false) + private Long chatId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "guestId", nullable = false) + private UserJpaEntity guest; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "hostId", nullable = false) + private UserJpaEntity host; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spaceId", nullable = false) + private SpaceJpaEntity space; + + @Builder.Default + @Column(name = "createdAt", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "lastMessageAt", nullable = false) + private LocalDateTime lastMessageAt; + + @Column(name = "lastReadAt", nullable = false) + private LocalDateTime lastReadAt; + +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/image/entity/ImageJpaEntity.java b/src/main/java/popcong/app/adapter/out/persistence/image/entity/ImageJpaEntity.java new file mode 100644 index 0000000..8e3fb89 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/image/entity/ImageJpaEntity.java @@ -0,0 +1,42 @@ +package popcong.app.adapter.out.persistence.image.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import popcong.app.domain.image.model.ImageableType; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "IMAGE") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor + +public class ImageJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "imageId", nullable = false) + private Long imageId; + + @Column(name = "imageUrl", nullable = false, length = 1000) + private String imageUrl; + + @Column(name = "saveOrder", nullable = false) + private Integer saveOrder; + + @Builder.Default + @Column(name = "createdAt", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "imageableId", nullable = false) + private Long imageableId; + + @Enumerated(EnumType.STRING) + @Column(name = "imageableType", nullable = false) + private ImageableType imageableType; +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/image/mapper/CoverImageMapper.java b/src/main/java/popcong/app/adapter/out/persistence/image/mapper/CoverImageMapper.java new file mode 100644 index 0000000..bbb73da --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/image/mapper/CoverImageMapper.java @@ -0,0 +1,12 @@ +package popcong.app.adapter.out.persistence.image.mapper; + +import org.springframework.stereotype.Component; +import popcong.app.adapter.out.persistence.image.entity.ImageJpaEntity; + +@Component +public class CoverImageMapper { + + public String toCoverImageUrlDto(ImageJpaEntity imageJpaEntity) { + return imageJpaEntity.getImageUrl(); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/out/persistence/image/repository/ImageJpaRepository.java b/src/main/java/popcong/app/adapter/out/persistence/image/repository/ImageJpaRepository.java new file mode 100644 index 0000000..7c46da6 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/image/repository/ImageJpaRepository.java @@ -0,0 +1,19 @@ +package popcong.app.adapter.out.persistence.image.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import popcong.app.adapter.out.persistence.image.entity.ImageJpaEntity; +import popcong.app.domain.image.model.ImageableType; + +import java.util.List; +import java.util.Optional; + +public interface ImageJpaRepository extends JpaRepository { + + Optional findByImageableTypeAndImageableIdAndSaveOrder( + ImageableType imageableType, Long imageableId, Integer saveOrder + ); + + List findByImageableIdAndImageableTypeOrderBySaveOrderAsc( + Long imageableId, ImageableType imageableType + ); +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/LoadMyPopupsPersistenceAdapter.java b/src/main/java/popcong/app/adapter/out/persistence/space/LoadMyPopupsPersistenceAdapter.java new file mode 100644 index 0000000..4100c40 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/LoadMyPopupsPersistenceAdapter.java @@ -0,0 +1,31 @@ +package popcong.app.adapter.out.persistence.space; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Repository; + +import popcong.app.adapter.out.persistence.space.entity.PopupJpaEntity; +import popcong.app.adapter.out.persistence.space.mapper.PopupEntityMapper; +import popcong.app.adapter.out.persistence.space.repository.PopupJpaRepository; +import popcong.app.application.space.dto.response.MyPopupItemResponseDto; +import popcong.app.application.user.port.out.LoadMyPopupsPort; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class LoadMyPopupsPersistenceAdapter implements LoadMyPopupsPort { + + private final PopupJpaRepository popupJpaRepository; + + @Override + public List findMyPopups(Long userId) { + + List rows = + popupJpaRepository.findByReservationUserUserIdOrderByStartDateAsc(userId); + + return rows.stream() + .map(PopupEntityMapper::toMyPopupItemResponseDto) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/entity/PopupJpaEntity.java b/src/main/java/popcong/app/adapter/out/persistence/space/entity/PopupJpaEntity.java new file mode 100644 index 0000000..3fe3554 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/entity/PopupJpaEntity.java @@ -0,0 +1,59 @@ +package popcong.app.adapter.out.persistence.space.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import popcong.app.domain.space.model.PopupStatus; +import popcong.app.domain.space.model.PopupType; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "POPUP") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PopupJpaEntity { + + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "popupId", nullable = false) + private Long popupId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservationId", nullable = false) + private ReservationJpaEntity reservation; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "popupStatus", nullable = false) + private PopupStatus popupStatus; + + @Column(name = "address", nullable = false, length = 100) + private String address; + + @Enumerated(EnumType.STRING) + @Column(name = "popupType", nullable = false) + private PopupType popupType; + + @Builder.Default + @Column(name = "price", nullable = false) + private Integer price = 0; + + @Column(name = "startDate", nullable = false) + private LocalDateTime startDate; + + @Column(name = "endDate", nullable = false) + private LocalDateTime endDate; + + @Builder.Default + @Column(name = "views", nullable = false) + private Integer views = 0; +} + diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/entity/ReservationJpaEntity.java b/src/main/java/popcong/app/adapter/out/persistence/space/entity/ReservationJpaEntity.java new file mode 100644 index 0000000..2f55f30 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/entity/ReservationJpaEntity.java @@ -0,0 +1,48 @@ +package popcong.app.adapter.out.persistence.space.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import popcong.app.adapter.out.persistence.user.entity.UserJpaEntity; +import popcong.app.domain.space.model.ReservationStatus; +import java.time.LocalDateTime; + +@Entity +@Table(name = "RESERVATION") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReservationJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reservationId") + private Long reservationId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "userId", nullable = false) + private UserJpaEntity user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spaceId", nullable = false) + private SpaceJpaEntity space; + + @Enumerated(EnumType.STRING) + @Column(name = "reservationStatus", nullable = false) + private ReservationStatus reservationStatus; + + @Column(name = "startDate", nullable = false) + private LocalDateTime startDate; + + @Column(name = "endDate", nullable = false) + private LocalDateTime endDate; + + @Column(name = "deposit", nullable = false) + private Integer deposit; + + @Column(name = "totalRentalFee", nullable = false) + private Integer totalRentalFee; +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/entity/ReservationReviewJpaEntity.java b/src/main/java/popcong/app/adapter/out/persistence/space/entity/ReservationReviewJpaEntity.java new file mode 100644 index 0000000..d1440d1 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/entity/ReservationReviewJpaEntity.java @@ -0,0 +1,37 @@ +package popcong.app.adapter.out.persistence.space.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import popcong.app.adapter.out.persistence.space.entity.ReservationJpaEntity; +import java.time.LocalDateTime; + +@Entity +@Table(name = "RESERVATION_REVIEW") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReservationReviewJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reservationReviewId", nullable = false) + private Long reservationReviewId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservationId", nullable = false) + private ReservationJpaEntity reservation; + + @Builder.Default + @Column(name = "rating", nullable = false) + private Double rating =0.0; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "createdAt", nullable = false) + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/entity/SpaceJpaEntity.java b/src/main/java/popcong/app/adapter/out/persistence/space/entity/SpaceJpaEntity.java new file mode 100644 index 0000000..a38fd9e --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/entity/SpaceJpaEntity.java @@ -0,0 +1,94 @@ +package popcong.app.adapter.out.persistence.space.entity; + + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import popcong.app.adapter.out.persistence.user.entity.UserJpaEntity; +import popcong.app.domain.space.model.PopupType; +import popcong.app.domain.space.model.SpaceApplicationType; +import popcong.app.domain.space.model.SpaceType; +import popcong.app.domain.space.model.Status; + + +@Entity +@Table(name = "SPACE") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SpaceJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "spaceId") + private Long spaceId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "userId", nullable = false) + private UserJpaEntity user; + + @Column(name = "spaceName", nullable = false, length = 50) + private String spaceName; + + @Enumerated(EnumType.STRING) + @Column(name = "spaceType", nullable = false) + private SpaceType spaceType; + + @Enumerated(EnumType.STRING) + @Column(name = "spaceApplicationType", nullable = false) + private SpaceApplicationType spaceApplicationType; // 공간 유형 + + @Enumerated(EnumType.STRING) + @Column(name = "spaceRentalType", nullable = false) + private PopupType spaceRentalType; + + @Builder.Default + @Column(name = "imageCount", nullable = false) + private Integer imageCount = 0; + + @Column(name = "description", nullable = false, columnDefinition = "TEXT") + private String description; + + @Builder.Default + @Column(name = "deposit", nullable = false) + private Integer deposit = 0; + + @Builder.Default + @Column(name = "rentalFee", nullable = false) + private Integer rentalFee = 0; + + @Column(name = "floor") + private Integer floor; + + @Column(name = "area", nullable = false) + private Double area; + + @Builder.Default + @Column(name = "rating", nullable = false) + private Double rating = 0.0; + + @Column(name = "address", nullable = false, length = 50) + private String address; + + @Column(name = "location", length = 30) + private String location; + + @Column(name = "latitude", nullable = false) + private Double latitude; + + @Column(name = "longitude", nullable = false) + private Double longitude; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status = Status.AVAILABLE; + + @Builder.Default + @Column(name = "views", nullable = false) + private Integer views = 0; +} + diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/mapper/PopupEntityMapper.java b/src/main/java/popcong/app/adapter/out/persistence/space/mapper/PopupEntityMapper.java new file mode 100644 index 0000000..2307c32 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/mapper/PopupEntityMapper.java @@ -0,0 +1,38 @@ +package popcong.app.adapter.out.persistence.space.mapper; + +import popcong.app.adapter.out.persistence.space.entity.PopupJpaEntity; +import popcong.app.adapter.out.persistence.space.entity.ReservationJpaEntity; +import popcong.app.adapter.out.persistence.space.entity.SpaceJpaEntity; +import popcong.app.application.space.dto.response.MyPopupItemResponseDto; +import popcong.app.application.space.dto.response.SpaceInfoResponseDto; + +public final class PopupEntityMapper { + private PopupEntityMapper() {} // 유틸클래스(인스턴스 생성 방지) + + public static MyPopupItemResponseDto toMyPopupItemResponseDto(PopupJpaEntity popup) { + //팝업이 속한 예약 엔티티 가져오기 + ReservationJpaEntity r = popup.getReservation(); + //예약 O -> 예약이 속한 공간 엔티티 가져오기 + SpaceJpaEntity s = (r != null) ? r.getSpace() : null; + + //MyPopupItemResponseDto 생성 + return new MyPopupItemResponseDto( + SpaceInfoResponseDto.of( //공간정보 부분 생성 + (s != null) ? s.getSpaceId() : null, //공간 ID + (s != null) ? s.getSpaceName() : null, // 공간 이름 + (s != null && s.getFloor() != null) + ? String.valueOf(s.getFloor()) + : null, + //층 O -> String 으로 변환 + (popup.getAddress() != null) //팝업 주소 O -> 사용, 팝업 주소 X -> 공간 주소 사용 + ? popup.getAddress() + : (s != null ? s.getAddress() : null) + ), + popup.getPopupId(), + popup.getName(), + popup.getPopupStatus(), + popup.getStartDate().toLocalDate(), + popup.getEndDate().toLocalDate() + ); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/mapper/SpaceMapper.java b/src/main/java/popcong/app/adapter/out/persistence/space/mapper/SpaceMapper.java new file mode 100644 index 0000000..50cf735 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/mapper/SpaceMapper.java @@ -0,0 +1,33 @@ +package popcong.app.adapter.out.persistence.space.mapper; + +import org.springframework.stereotype.Component; +import popcong.app.adapter.out.persistence.space.entity.SpaceJpaEntity; +import popcong.app.domain.space.model.Space; + +@Component +public class SpaceMapper { + + public Space toDomain(SpaceJpaEntity entity) { + return new Space( + entity.getSpaceId(), + entity.getUser().getUserId(), + entity.getSpaceName(), + entity.getSpaceType(), + entity.getSpaceApplicationType(), + entity.getSpaceRentalType(), + entity.getImageCount(), + entity.getDescription(), + entity.getDeposit(), + entity.getRentalFee(), + entity.getFloor(), + entity.getArea(), + entity.getRating(), + entity.getAddress(), + entity.getLocation(), + entity.getLatitude(), + entity.getLongitude(), + entity.getStatus(), + entity.getViews() + ); + } +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/mapper/SpaceMarkerMapper.java b/src/main/java/popcong/app/adapter/out/persistence/space/mapper/SpaceMarkerMapper.java new file mode 100644 index 0000000..d2ed328 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/mapper/SpaceMarkerMapper.java @@ -0,0 +1,34 @@ +package popcong.app.adapter.out.persistence.space.mapper; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import popcong.app.application.image.port.in.ImageQueryUseCase; +import popcong.app.application.space.dto.response.MarkerComponentDto; +import popcong.app.domain.image.model.ImageableType; +import popcong.app.domain.space.model.Space; + +@Component +@RequiredArgsConstructor +public class SpaceMarkerMapper { + + private final ImageQueryUseCase imageQueryUseCase; + + public MarkerComponentDto toMarkerDto(Space space, Long straightDistance, int reviewCount) { + + String coverImageUrl = imageQueryUseCase.getCoverImageUrl(ImageableType.SPACE, space.spaceId()); + + return new MarkerComponentDto( + space.spaceId(), + space.latitude(), + space.longitude(), + space.spaceName(), + coverImageUrl, + space.rentalFee(), + space.address(), + space.floor(), + space.rating(), + reviewCount, + straightDistance + ); + } +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/repository/PopupJpaRepository.java b/src/main/java/popcong/app/adapter/out/persistence/space/repository/PopupJpaRepository.java new file mode 100644 index 0000000..51fdb74 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/repository/PopupJpaRepository.java @@ -0,0 +1,14 @@ +package popcong.app.adapter.out.persistence.space.repository; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import popcong.app.adapter.out.persistence.space.entity.PopupJpaEntity; + +import java.util.List; + +public interface PopupJpaRepository extends JpaRepository { + + // 사용자별 팝업 목록 조회 (시작일 오름차순) + @EntityGraph(attributePaths = {"reservation", "reservation.space"}) // N+1 방지 + List findByReservationUserUserIdOrderByStartDateAsc(Long userId); +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/repository/ReviewCountQueryAdapter.java b/src/main/java/popcong/app/adapter/out/persistence/space/repository/ReviewCountQueryAdapter.java new file mode 100644 index 0000000..b79244d --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/repository/ReviewCountQueryAdapter.java @@ -0,0 +1,68 @@ +package popcong.app.adapter.out.persistence.space.repository; + +import com.querydsl.core.Tuple; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import popcong.app.adapter.out.persistence.space.entity.QReservationJpaEntity; +import popcong.app.adapter.out.persistence.space.entity.QReservationReviewJpaEntity; +import popcong.app.adapter.out.persistence.space.entity.QSpaceJpaEntity; +import popcong.app.application.space.port.out.ReviewCountQueryPort; + +import java.util.*; + +@Repository +@RequiredArgsConstructor +public class ReviewCountQueryAdapter implements ReviewCountQueryPort { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Long countBySpaceId(long spaceId) { + QReservationReviewJpaEntity rr = QReservationReviewJpaEntity.reservationReviewJpaEntity; + QReservationJpaEntity r = QReservationJpaEntity.reservationJpaEntity; + QSpaceJpaEntity s = QSpaceJpaEntity.spaceJpaEntity; + + Long count = jpaQueryFactory + .select(rr.count()) + .from(rr) + .join(rr.reservation, r) + .join(r.space, s) + .where(s.spaceId.eq(spaceId)) + .fetchOne(); + + return count != null ? count : 0; + } + + @Override + public Map countBySpaceIds(Collection spaceIds) { + if (spaceIds == null || spaceIds.isEmpty()) { + return Collections.emptyMap(); + } + + QReservationReviewJpaEntity rr = QReservationReviewJpaEntity.reservationReviewJpaEntity; + QReservationJpaEntity r = QReservationJpaEntity.reservationJpaEntity; + QSpaceJpaEntity s = QSpaceJpaEntity.spaceJpaEntity; + + List rows = jpaQueryFactory + .select(s.spaceId, rr.count()) + .from(rr) + .join(rr.reservation, r) + .join(r.space, s) + .where(s.spaceId.in(spaceIds)) + .groupBy(s.spaceId) + .fetch(); + + Map map = new HashMap<>(); + + for (Tuple t : rows) { + map.put(t.get(s.spaceId), t.get(rr.count())); + } + + for (Long id : spaceIds) { + map.putIfAbsent(id, 0L); + } + + return map; + } +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/repository/SpaceJpaRepository.java b/src/main/java/popcong/app/adapter/out/persistence/space/repository/SpaceJpaRepository.java new file mode 100644 index 0000000..36013a5 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/repository/SpaceJpaRepository.java @@ -0,0 +1,8 @@ +package popcong.app.adapter.out.persistence.space.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import popcong.app.adapter.out.persistence.space.entity.SpaceJpaEntity; + +public interface SpaceJpaRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/space/repository/SpaceMapQueryAdapter.java b/src/main/java/popcong/app/adapter/out/persistence/space/repository/SpaceMapQueryAdapter.java new file mode 100644 index 0000000..b1fb608 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/space/repository/SpaceMapQueryAdapter.java @@ -0,0 +1,80 @@ +package popcong.app.adapter.out.persistence.space.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import popcong.app.adapter.out.persistence.space.entity.QReservationJpaEntity; +import popcong.app.adapter.out.persistence.space.entity.QReservationReviewJpaEntity; +import popcong.app.adapter.out.persistence.space.entity.QSpaceJpaEntity; +import popcong.app.adapter.out.persistence.space.entity.SpaceJpaEntity; +import popcong.app.adapter.out.persistence.space.mapper.SpaceMapper; +import popcong.app.application.space.port.out.SpaceMapQueryPort; +import popcong.app.domain.space.model.Space; +import popcong.app.domain.space.model.SpaceSortType; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.SpaceErrorCode; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class SpaceMapQueryAdapter implements SpaceMapQueryPort { + +// private final EntityManager entityManager; + private final JPAQueryFactory jpaQueryFactory; + private final SpaceMapper spaceMapper; + + @Override + public List findSpacesInDisplayWithFilters( + Double nwLat, + Double nwLng, + Double seLat, + Double seLng, + Integer minAmount, + Integer maxAmount, + Integer floor, + Double rating, + SpaceSortType sortType + ) { + + if (nwLat == null || nwLng == null || seLat == null || seLng == null) { + throw new BusinessException(SpaceErrorCode.INVALID_BOUDING_BOX); + } + + // 박스 정규화 + double minLat = Math.min(nwLat, seLat); + double maxLat = Math.max(nwLat, seLat); + double minLng = Math.min(nwLng, seLng); + double maxLng = Math.max(nwLng, seLng); + + QSpaceJpaEntity s = QSpaceJpaEntity.spaceJpaEntity; +// QReservationJpaEntity r = QReservationJpaEntity.reservationJpaEntity; +// QReservationReviewJpaEntity rr = QReservationReviewJpaEntity.reservationReviewJpaEntity; + + BooleanExpression inBox = s.latitude.between(minLat, maxLat).and(s.longitude.between(minLng, maxLng)); + BooleanExpression feeMin = (minAmount != null) ? s.rentalFee.goe(minAmount) : null; + BooleanExpression feeMax = (maxAmount != null) ? s.rentalFee.loe(maxAmount) : null; + BooleanExpression floorEq = (floor != null) ? s.floor.eq(floor) : null; + BooleanExpression ratingG = (rating != null) ? s.rating.goe(rating) : null; + + if (sortType == null) { + sortType = SpaceSortType.MOST_POPULAR; + } + + List entities = switch (sortType) { + case MOST_POPULAR -> jpaQueryFactory.selectFrom(s) + .where(inBox, feeMin, feeMax, floorEq, ratingG) + .orderBy(s.views.desc(), s.spaceId.asc()) + .fetch(); + case NEAREST, MOST_REVIEWS -> jpaQueryFactory + .selectFrom(s) + .where(inBox, feeMin, feeMax, floorEq, ratingG) + .orderBy(s.spaceId.asc()) + .fetch(); + }; + + return entities.stream().map(spaceMapper::toDomain).toList(); + } +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/user/Mapper/UserMapper.java b/src/main/java/popcong/app/adapter/out/persistence/user/Mapper/UserMapper.java new file mode 100644 index 0000000..7477306 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/user/Mapper/UserMapper.java @@ -0,0 +1,43 @@ +package popcong.app.adapter.out.persistence.user.Mapper; + +import org.springframework.stereotype.Component; +import popcong.app.adapter.out.persistence.user.entity.UserJpaEntity; +import popcong.app.domain.user.model.User; + +@Component +public class UserMapper { + + // Entity -> Domain + public User toDomain(UserJpaEntity entity) { + return new User( + entity.getUserId(), + entity.getProvider(), + entity.getProviderId(), + entity.getEmail(), + entity.getName(), + entity.getProfileImageUrl(), + entity.getIntroduction(), + entity.getRole(), + entity.getUserRole(), + entity.getCreatedAt(), + entity.getDeletedAt() + ); + } + + // Domain -> Entity + public UserJpaEntity toEntity(User domain) { + return UserJpaEntity.builder() + .userId(domain.userId()) + .provider(domain.provider()) + .providerId(domain.providerId()) + .email(domain.email()) + .name(domain.name()) + .profileImageUrl(domain.profileImageUrl()) + .introduction(domain.introduction()) + .role(domain.role()) + .userRole(domain.userRole()) + .createdAt(domain.createdAt()) + .deletedAt(domain.deletedAt()) + .build(); + } +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/user/entity/NotificationJpaEntity.java b/src/main/java/popcong/app/adapter/out/persistence/user/entity/NotificationJpaEntity.java new file mode 100644 index 0000000..3dcbb37 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/user/entity/NotificationJpaEntity.java @@ -0,0 +1,53 @@ +package popcong.app.adapter.out.persistence.user.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import popcong.app.adapter.out.persistence.space.entity.SpaceJpaEntity; +import popcong.app.adapter.out.persistence.user.entity.UserJpaEntity; +import popcong.app.domain.user.model.NotificationType; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "NOTIFICATION") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor + +public class NotificationJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notificationId", nullable = false) + private Long notificationId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "userId", nullable = false) + private UserJpaEntity user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spaceId", nullable = false) + private SpaceJpaEntity space; + + @Enumerated(EnumType.STRING) + @Column(name = "notificationType", nullable = false) + private NotificationType notificationType; + + @Column(name = "message", nullable = false, length = 225) + private String message; + + @Column(name = "url", nullable = false, length = 225) + private String url; + + @Builder.Default + @Column(name = "isRead", nullable = false) + private Boolean isRead = false; + + @Builder.Default + @Column(name = "createdAt", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/user/entity/UserJpaEntity.java b/src/main/java/popcong/app/adapter/out/persistence/user/entity/UserJpaEntity.java new file mode 100644 index 0000000..890551c --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/user/entity/UserJpaEntity.java @@ -0,0 +1,62 @@ +package popcong.app.adapter.out.persistence.user.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import popcong.app.domain.user.model.Provider; +import popcong.app.domain.user.model.Role; +import popcong.app.domain.user.model.UserRole; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "USER") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "userId", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false) + private Provider provider; + + @Column(name = "providerId", nullable = false, unique = true, length = 50) + private String providerId; + + @Column(name = "email", nullable = false, unique = true, length = 50) + private String email; + + @Column(name = "name", nullable = false, length = 10) + private String name; + + @Column(name = "profileImageUrl") + private String profileImageUrl; + + @Column(name = "introduction", length = 1000) + private String introduction; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private Role role = Role.ROLE_USER; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "userRole", nullable = false) + private UserRole userRole = UserRole.GUEST; + + @Builder.Default + @Column(name = "createdAt", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "deletedAt") + private LocalDateTime deletedAt; +} \ No newline at end of file diff --git a/src/main/java/popcong/app/adapter/out/persistence/user/entity/WishlistJpaEntity.java b/src/main/java/popcong/app/adapter/out/persistence/user/entity/WishlistJpaEntity.java new file mode 100644 index 0000000..390e4f8 --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/user/entity/WishlistJpaEntity.java @@ -0,0 +1,39 @@ +package popcong.app.adapter.out.persistence.user.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import popcong.app.adapter.out.persistence.space.entity.SpaceJpaEntity; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "WISHLIST") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WishlistJpaEntity { + + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long wishlistId; + + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "userId", nullable = false) + private UserJpaEntity user; + + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spaceId", nullable = false) + private SpaceJpaEntity space; + + + @Builder.Default + @Column(name = "createdAt", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/user/repository/UserAdapter.java b/src/main/java/popcong/app/adapter/out/persistence/user/repository/UserAdapter.java new file mode 100644 index 0000000..0bf1a1e --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/user/repository/UserAdapter.java @@ -0,0 +1,37 @@ +package popcong.app.adapter.out.persistence.user.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import popcong.app.adapter.out.persistence.user.Mapper.UserMapper; +import popcong.app.adapter.out.persistence.user.entity.UserJpaEntity; +import popcong.app.domain.user.model.User; +import popcong.app.application.user.port.out.LoadUserPort; +import popcong.app.application.user.port.out.SaveUserPort; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UserAdapter implements LoadUserPort, SaveUserPort { + + private final UserJpaRepository userJpaRepository; + private final UserMapper userMapper; + + @Override + public Optional loadUserByEmail(String email) { + return userJpaRepository.findByEmail(email) + .map(userMapper::toDomain); + } + + @Override + public Optional loadUserById(Long id) { + return userJpaRepository.findById(id) + .map(userMapper::toDomain); + } + + @Override + public User saveUser(User user) { + UserJpaEntity entity = userJpaRepository.save(userMapper.toEntity(user)); + return userMapper.toDomain(entity); + } +} diff --git a/src/main/java/popcong/app/adapter/out/persistence/user/repository/UserJpaRepository.java b/src/main/java/popcong/app/adapter/out/persistence/user/repository/UserJpaRepository.java new file mode 100644 index 0000000..f824fbf --- /dev/null +++ b/src/main/java/popcong/app/adapter/out/persistence/user/repository/UserJpaRepository.java @@ -0,0 +1,11 @@ +package popcong.app.adapter.out.persistence.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import popcong.app.adapter.out.persistence.user.entity.UserJpaEntity; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByEmail(String email); + Optional findById(Long id); +} diff --git a/src/main/java/popcong/app/application/auth/port/in/GoogleDriveSubmitUseCase.java b/src/main/java/popcong/app/application/auth/port/in/GoogleDriveSubmitUseCase.java new file mode 100644 index 0000000..bafa95e --- /dev/null +++ b/src/main/java/popcong/app/application/auth/port/in/GoogleDriveSubmitUseCase.java @@ -0,0 +1,15 @@ +package popcong.app.application.auth.port.in; + +import popcong.app.domain.user.model.SignUpUserType; +import popcong.app.domain.user.model.UploadItem; + +import java.util.List; + +public interface GoogleDriveSubmitUseCase { + List submitUserDocs( + SignUpUserType userRole, + Long userId, + String email, + List items + ); +} diff --git a/src/main/java/popcong/app/application/auth/port/in/OAuth2SignInUseCase.java b/src/main/java/popcong/app/application/auth/port/in/OAuth2SignInUseCase.java new file mode 100644 index 0000000..953af30 --- /dev/null +++ b/src/main/java/popcong/app/application/auth/port/in/OAuth2SignInUseCase.java @@ -0,0 +1,10 @@ +package popcong.app.application.auth.port.in; + +import popcong.app.domain.auth.model.OAuth2SignInCommand; +import popcong.app.domain.user.model.User; + +// 로그인(사용자 인증 후 토큰 발급) 인터페이스 +public interface OAuth2SignInUseCase { + // 카카오 정보만으로 로그인 및 기본 사용자 생성 + User findOrCreateUser(OAuth2SignInCommand oAuth2SignInCommand); +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/auth/port/in/TokenGenerateUseCase.java b/src/main/java/popcong/app/application/auth/port/in/TokenGenerateUseCase.java new file mode 100644 index 0000000..103531a --- /dev/null +++ b/src/main/java/popcong/app/application/auth/port/in/TokenGenerateUseCase.java @@ -0,0 +1,7 @@ +package popcong.app.application.auth.port.in; + +import popcong.app.domain.auth.model.AuthInfo; + +public interface TokenGenerateUseCase { + String generateToken(AuthInfo authInfo); +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/auth/port/out/OAuth2UserInfoPort.java b/src/main/java/popcong/app/application/auth/port/out/OAuth2UserInfoPort.java new file mode 100644 index 0000000..8035f7b --- /dev/null +++ b/src/main/java/popcong/app/application/auth/port/out/OAuth2UserInfoPort.java @@ -0,0 +1,12 @@ +package popcong.app.application.auth.port.out; + +import popcong.app.domain.user.model.Provider; + +/** + * 카카오에서 제공하는 attributes 에서 정보 가져오기 + */ +public interface OAuth2UserInfoPort { + Provider getProvider(); + String getProviderId(); + String getEmail(); +} diff --git a/src/main/java/popcong/app/application/auth/service/AuthService.java b/src/main/java/popcong/app/application/auth/service/AuthService.java new file mode 100644 index 0000000..1cff9f6 --- /dev/null +++ b/src/main/java/popcong/app/application/auth/service/AuthService.java @@ -0,0 +1,47 @@ +package popcong.app.application.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import popcong.app.adapter.out.jwt.JwtUtils; +import popcong.app.application.auth.port.in.OAuth2SignInUseCase; +import popcong.app.application.user.port.out.LoadUserPort; +import popcong.app.application.user.port.out.SaveUserPort; +import popcong.app.domain.auth.model.OAuth2SignInCommand; +import popcong.app.domain.user.model.Role; +import popcong.app.domain.user.model.User; +import popcong.app.domain.user.model.UserRole; + +import java.time.LocalDateTime; + +@Service +@Transactional +@RequiredArgsConstructor +public class AuthService implements OAuth2SignInUseCase { + + private final LoadUserPort loadUserPort; + private final SaveUserPort saveUserPort; + + private final JwtUtils jwtUtils; + + @Override + public User findOrCreateUser(OAuth2SignInCommand command) { + return loadUserPort.loadUserByEmail(command.email()) + .orElseGet(() -> { + User newUser = new User( + null, + command.provider(), + command.providerId(), + command.email(), + "GUEST", + null, + null, + Role.ROLE_USER, + UserRole.GUEST, + LocalDateTime.now(), + null + ); + return saveUserPort.saveUser(newUser); + }); + } +} diff --git a/src/main/java/popcong/app/application/auth/service/GoogleDriveSubmitService.java b/src/main/java/popcong/app/application/auth/service/GoogleDriveSubmitService.java new file mode 100644 index 0000000..57641b6 --- /dev/null +++ b/src/main/java/popcong/app/application/auth/service/GoogleDriveSubmitService.java @@ -0,0 +1,148 @@ +package popcong.app.application.auth.service; + +import com.google.api.client.http.InputStreamContent; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; +import com.google.api.services.drive.model.FileList; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import popcong.app.application.auth.port.in.GoogleDriveSubmitUseCase; +import popcong.app.domain.user.model.SignUpUserType; +import popcong.app.domain.user.model.UploadItem; +import popcong.app.infra.config.gcp.GcpApiProperties; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class GoogleDriveSubmitService implements GoogleDriveSubmitUseCase { + + private final Drive drive; + private final GcpApiProperties gcpApiProperties; + + @Override + public List submitUserDocs( + SignUpUserType userType, // GUEST, HOST + Long userId, + String email, + List items + ) { + try { + // drive id, name 가져오기 + String rootDriveId = getDriveId(userType); + + // drive 이름 생성 + String userDriveName = userId + "_" + sanitize(email); + + // 드라이브 보장 + String userFolderId = setUserDriveUnderRoot(rootDriveId, userDriveName); + + List ids = new ArrayList<>(); + for (UploadItem item : items) { + MultipartFile multipartFile = item.file(); + + // 파일명에 타입 추가 + String prefixedName = item.type().name().toLowerCase() + "_" + multipartFile.getOriginalFilename(); + + try (InputStream inputStream = multipartFile.getInputStream()) { + + String id = uploadFile( + userFolderId, + prefixedName, + inputStream, + safeMime(multipartFile.getContentType()) + ); + ids.add(id); + } + } + + return ids; + } catch (IOException e) { + throw new RuntimeException("Google Drive 업로드 실패", e); + } + } + + + // 유저 타입에 따라 다른 Google Drive ID 가져오기 (폴더 보장) + private String getDriveId(SignUpUserType userType) { + return (Objects.equals(userType, SignUpUserType.GUEST)) + ? gcpApiProperties.getGuestDriveId() : gcpApiProperties.getHostDriveId(); + } + + private String setUserDriveUnderRoot( + String rootDriveId, + String folderName + ) throws IOException { + String q = "mimeType='application/vnd.google-apps.folder' " + + "and name='" + escape(folderName) + "' " + + "and '" + rootDriveId + "' in parents " + + "and trashed=false"; + + FileList fileList = drive.files().list() + .setQ(q) + .setSupportsAllDrives(true) + .setIncludeItemsFromAllDrives(true) + .setSpaces("drive") + .setFields("files(id)") + .execute(); + + if (fileList.getFiles() != null && !fileList.getFiles().isEmpty()) { + return fileList.getFiles().get(0).getId(); + } + + File meta = new File(); + meta.setName(folderName); + meta.setMimeType("application/vnd.google-apps.folder"); + meta.setParents(List.of(rootDriveId)); + + File createdFile = drive.files().create(meta) + .setSupportsAllDrives(true) + .setFields("id") + .execute(); + + return createdFile.getId(); + } + + // 파일 업로드 + private String uploadFile( + String parentFolderId, + String fileName, + InputStream content, + String fileFormatType + ) throws IOException { + File file = new File(); + file.setName(fileName); + file.setParents(List.of(parentFolderId)); + + var media = new InputStreamContent(fileFormatType, content); + + File created = drive.files().create(file, media) + .setSupportsAllDrives(true) + .setFields("id, parents") + .execute(); + + return created.getId(); + } + + // ===== 파일명 형식 관련 valid 처리 ===== + private static String safeMime(String mime) { + return (mime == null || mime.isBlank()) ? "application/octet-stream" : mime; + } + + private static String sanitize(String s) { + return s.replaceAll("[\\\\/:*?\"<>|#\\[\\]]", "_").trim(); + } + + private static String escape(String s) { + return s.replace("'", "\\'"); + } +} diff --git a/src/main/java/popcong/app/application/auth/service/token/AccessTokenGenerateService.java b/src/main/java/popcong/app/application/auth/service/token/AccessTokenGenerateService.java new file mode 100644 index 0000000..e9ae687 --- /dev/null +++ b/src/main/java/popcong/app/application/auth/service/token/AccessTokenGenerateService.java @@ -0,0 +1,40 @@ +package popcong.app.application.auth.service.token; + +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import popcong.app.domain.auth.model.AuthInfo; +import popcong.app.application.auth.port.in.TokenGenerateUseCase; +import popcong.app.infra.config.jwt.JwtProperties; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Service +@RequiredArgsConstructor +public class AccessTokenGenerateService implements TokenGenerateUseCase { + + private final JwtProperties jwtProperties; + private final SecretKey secretKey; + + /** + * AuthInfo의 사용자 id, providerId, email, role, userRole로 Access Token 생성 + * @param authInfo + * @return accessToken 발급 + */ + @Override + public String generateToken(AuthInfo authInfo) { + Date now = new Date(); + Long expiration = jwtProperties.getExpirationTime().getAccessToken(); + + return Jwts.builder() + .setSubject(authInfo.id().toString()) + .claim("email", authInfo.email()) + .claim("role", authInfo.role()) + .claim("tokenType", "ACCESS") + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + expiration)) + .signWith(secretKey) + .compact(); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/auth/service/token/RefreshTokenGenerateService.java b/src/main/java/popcong/app/application/auth/service/token/RefreshTokenGenerateService.java new file mode 100644 index 0000000..faf1986 --- /dev/null +++ b/src/main/java/popcong/app/application/auth/service/token/RefreshTokenGenerateService.java @@ -0,0 +1,39 @@ +package popcong.app.application.auth.service.token; + +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import popcong.app.domain.auth.model.AuthInfo; +import popcong.app.application.auth.port.in.TokenGenerateUseCase; +import popcong.app.infra.config.jwt.JwtProperties; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Service +@RequiredArgsConstructor +public class RefreshTokenGenerateService implements TokenGenerateUseCase { + + private final JwtProperties jwtProperties; + private final SecretKey secretKey; + + /** + * AuthInfo의 사용자 id로 Refresh Token 생성 + * @param authInfo + * @return refreshToken 발급 + */ + @Override + public String generateToken(AuthInfo authInfo) { + Date now = new Date(); + Long expiration = jwtProperties.getExpirationTime().getRefreshToken(); + + return Jwts.builder() + .setSubject(authInfo.id().toString()) + .claim("role", authInfo.role()) + .claim("tokenType", "REFRESH") + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + expiration)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/popcong/app/application/image/port/in/GeoDistanceUseCase.java b/src/main/java/popcong/app/application/image/port/in/GeoDistanceUseCase.java new file mode 100644 index 0000000..9ff7e43 --- /dev/null +++ b/src/main/java/popcong/app/application/image/port/in/GeoDistanceUseCase.java @@ -0,0 +1,5 @@ +package popcong.app.application.image.port.in; + +public interface GeoDistanceUseCase { + Long getStraightDistance(Double lat1, Double lng1, Double lat2, Double lng2); +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/image/port/in/ImageQueryUseCase.java b/src/main/java/popcong/app/application/image/port/in/ImageQueryUseCase.java new file mode 100644 index 0000000..b8ba00b --- /dev/null +++ b/src/main/java/popcong/app/application/image/port/in/ImageQueryUseCase.java @@ -0,0 +1,7 @@ +package popcong.app.application.image.port.in; + +import popcong.app.domain.image.model.ImageableType; + +public interface ImageQueryUseCase { + String getCoverImageUrl(ImageableType imageableType, Long imageableId); +} diff --git a/src/main/java/popcong/app/application/image/service/GeoDistanceService.java b/src/main/java/popcong/app/application/image/service/GeoDistanceService.java new file mode 100644 index 0000000..bbfd71f --- /dev/null +++ b/src/main/java/popcong/app/application/image/service/GeoDistanceService.java @@ -0,0 +1,35 @@ +package popcong.app.application.image.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import popcong.app.application.image.port.in.GeoDistanceUseCase; + +@Service +@RequiredArgsConstructor +public class GeoDistanceService implements GeoDistanceUseCase { + + private static final double EARTH_RADIUS_M = 6371000.0; // 지구 반지름 (m) + + // (lat1, lng1)과 (lat2, lng2) 사이 거리 계산 후 반올림 정수 값 반환 + public Long getStraightDistance(Double lat1, Double lng1, Double lat2, Double lng2) { + + if (lat1 == null || lng1 == null || lat2 == null || lng2 == null) { + return null; + } + + double rLat1 = Math.toRadians(lat1); + double rLat2 = Math.toRadians(lat2); + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lng2 - lng1); + + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(rLat1) * Math.cos(rLat2) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + Long distance = Math.round(EARTH_RADIUS_M * c);; + + return distance; + } +} diff --git a/src/main/java/popcong/app/application/image/service/ImageQueryService.java b/src/main/java/popcong/app/application/image/service/ImageQueryService.java new file mode 100644 index 0000000..18ab06f --- /dev/null +++ b/src/main/java/popcong/app/application/image/service/ImageQueryService.java @@ -0,0 +1,23 @@ +package popcong.app.application.image.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import popcong.app.adapter.out.persistence.image.mapper.CoverImageMapper; +import popcong.app.adapter.out.persistence.image.repository.ImageJpaRepository; +import popcong.app.application.image.port.in.ImageQueryUseCase; +import popcong.app.domain.image.model.ImageableType; + +@Service +@RequiredArgsConstructor +public class ImageQueryService implements ImageQueryUseCase { + + private final ImageJpaRepository imageJpaRepository; + private final CoverImageMapper coverImageMapper; + + public String getCoverImageUrl(ImageableType imageableType, Long imageableId) { + return imageJpaRepository + .findByImageableTypeAndImageableIdAndSaveOrder(imageableType, imageableId, 1) + .map(coverImageMapper::toCoverImageUrlDto) + .orElse(null); + } +} diff --git a/src/main/java/popcong/app/application/image/service/ImageService.java b/src/main/java/popcong/app/application/image/service/ImageService.java new file mode 100644 index 0000000..d0192fb --- /dev/null +++ b/src/main/java/popcong/app/application/image/service/ImageService.java @@ -0,0 +1,88 @@ +package popcong.app.application.image.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import popcong.app.adapter.out.persistence.image.entity.ImageJpaEntity; +import popcong.app.adapter.out.persistence.image.repository.ImageJpaRepository; +import popcong.app.domain.image.model.ImageableType; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.ImageS3ErrorCode; + +import java.util.List; +import java.util.Locale; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageService { + + private final S3Service s3Service; // S3 업로드 담당 + private final ImageJpaRepository imageRepository; // Image 테이블 JPA + + /** 업로드 + DB 저장 */ + public ImageJpaEntity upload( + ImageableType imageableType, + Long imageableId, + int saveOrder, + MultipartFile file) { + + if (file == null || file.isEmpty()) { + throw new BusinessException(ImageS3ErrorCode.FILE_NOT_FOUND); + } + + // 키 생성 (profile/123/0.jpg) + String key = buildKey(imageableType, imageableId, saveOrder, file); + + // S3 업로드 후 URL 반환 + String url = s3Service.uploadFile(key, file); + + // DB에 저장 (있으면 업데이트, 없으면 새로 생성) + ImageJpaEntity entity = imageRepository + .findByImageableTypeAndImageableIdAndSaveOrder(imageableType, imageableId, Integer.valueOf(saveOrder)) + .map(existing -> ImageJpaEntity.builder() + .imageId(existing.getImageId()) + .imageUrl(url) + .saveOrder(saveOrder) + .imageableId(imageableId) + .imageableType(imageableType) + .build() + ) + .orElseGet(() -> ImageJpaEntity.builder() + .imageUrl(url) + .saveOrder(saveOrder) + .imageableId(imageableId) + .imageableType(imageableType) + .build() + ); + + return imageRepository.save(entity); + } + + /** 목록 조회 */ + public List list(Long imageableId, ImageableType imageableType) { + return imageRepository.findByImageableIdAndImageableTypeOrderBySaveOrderAsc( + imageableId, imageableType + ); + } + + // 내부 유틸 + + private String buildKey(ImageableType type, Long imageableId, int saveOrder, MultipartFile file) { + String prefix = type.name().toLowerCase(Locale.ROOT); + String ext = guessExt(file); + return prefix + "/" + imageableId + "/" + saveOrder + "." + ext; + } + + private String guessExt(MultipartFile file) { + String name = file.getOriginalFilename(); + if (name != null) { + int dot = name.lastIndexOf('.'); + if (dot > -1 && dot < name.length() - 1) { + return name.substring(dot + 1).toLowerCase(Locale.ROOT); + } + } + return "jpg"; + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/image/service/S3Service.java b/src/main/java/popcong/app/application/image/service/S3Service.java new file mode 100644 index 0000000..5be44ae --- /dev/null +++ b/src/main/java/popcong/app/application/image/service/S3Service.java @@ -0,0 +1,73 @@ + +package popcong.app.application.image.service; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.ImageS3ErrorCode; + +import java.io.IOException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + + // 성공 시 S3 URL 반환 + public String uploadFile(String key, MultipartFile file) { + + // 입력 검증 + if (file == null || file.isEmpty()) { + throw new BusinessException(ImageS3ErrorCode.FILE_NOT_FOUND); + } + try { + //메타 데이터 설정 + ObjectMetadata md = new ObjectMetadata(); + md.setContentLength(file.getSize()); // S3가 스트림을 읽을 크기 + if (file.getContentType() != null) { //브라우저/클라이언트가 파일을 다룰때 도움 + md.setContentType(file.getContentType()); //다운로드 표시에 유용한 헤더 + } + + // 로그확인 + log.info("[S3 PUT] bucket={}, key={}, size={}, contentType={}", + bucket, key, file.getSize(), file.getContentType()); + + //key "버킷 내부 경로/파일명", 호출자가 key를 만들어 넘김 -> 서비스는 업로드만 담당 + amazonS3.putObject(bucket, key, file.getInputStream(), md); + return amazonS3.getUrl(bucket, key).toString(); //getUrl -> 저장 위치 식별자로 씀 + + } catch (IOException e) { + log.warn("S3 upload IOException (key={}): {}", key, e.getMessage(), e); + throw new BusinessException(ImageS3ErrorCode.FILE_UPLOAD_ERROR); + + } catch (AmazonServiceException e) { + //S3가 HTTP 오류로 응답한 경우 + int status = e.getStatusCode(); + log.warn("S3 upload failed: status={}, awsCode={}, msg={}", + status, e.getErrorCode(), e.getMessage(), e); + + if (status == 403) throw new BusinessException(ImageS3ErrorCode.STORAGE_ACCESS_DENIED); //권한 없음 + if (status == 404) throw new BusinessException(ImageS3ErrorCode.FILE_NOT_FOUND); // 파일 없음 + if (status == 413) throw new BusinessException(ImageS3ErrorCode.FILE_TOO_LARGE); //용량 + + throw new BusinessException(ImageS3ErrorCode.FILE_UPLOAD_ERROR); + + } catch (RuntimeException e) { //네트워크 문제등을 포함한 대부분 런타임 문제 + log.warn("S3 client/runtime error (key={}): {}", key, e.getMessage(), e); + throw new BusinessException(ImageS3ErrorCode.FILE_UPLOAD_ERROR); + + } + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/space/dto/request/CurrentUserLocationRequestDto.java b/src/main/java/popcong/app/application/space/dto/request/CurrentUserLocationRequestDto.java new file mode 100644 index 0000000..56ae721 --- /dev/null +++ b/src/main/java/popcong/app/application/space/dto/request/CurrentUserLocationRequestDto.java @@ -0,0 +1,7 @@ +package popcong.app.application.space.dto.request; + +public record CurrentUserLocationRequestDto( + Double latitude, + Double longitude +) { +} diff --git a/src/main/java/popcong/app/application/space/dto/response/MarkerComponentDto.java b/src/main/java/popcong/app/application/space/dto/response/MarkerComponentDto.java new file mode 100644 index 0000000..8e464cc --- /dev/null +++ b/src/main/java/popcong/app/application/space/dto/response/MarkerComponentDto.java @@ -0,0 +1,16 @@ +package popcong.app.application.space.dto.response; + +public record MarkerComponentDto( + Long spaceId, + Double latitude, // 위도 + Double longitude, // 경도 + String spaceName, // 공간 이름 + String coverImageUrl, // 대표이미지 경로 + Integer rentalFee, // 대여료 + String address, // 주소 + Integer floor, // 층수 + Double rating, // 공간 평점 + Integer reviewCount, // 공간 후기 수 + Long distance // 현위치와의 거리 +) { +} diff --git a/src/main/java/popcong/app/application/space/dto/response/MarkerListResponseDto.java b/src/main/java/popcong/app/application/space/dto/response/MarkerListResponseDto.java new file mode 100644 index 0000000..a34df90 --- /dev/null +++ b/src/main/java/popcong/app/application/space/dto/response/MarkerListResponseDto.java @@ -0,0 +1,7 @@ +package popcong.app.application.space.dto.response; + +import java.util.List; + +public record MarkerListResponseDto( + List markers +) {} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/space/dto/response/MyPopupItemResponseDto.java b/src/main/java/popcong/app/application/space/dto/response/MyPopupItemResponseDto.java new file mode 100644 index 0000000..5f3d1e8 --- /dev/null +++ b/src/main/java/popcong/app/application/space/dto/response/MyPopupItemResponseDto.java @@ -0,0 +1,30 @@ +package popcong.app.application.space.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import popcong.app.domain.space.model.PopupStatus; + + +import java.time.LocalDate; + +//외부 응답 전용 Dto +@JsonInclude(JsonInclude.Include.NON_NULL) // 값이 Null인 필드를 응답에서 제외 +public record MyPopupItemResponseDto( + SpaceInfoResponseDto spaceInfo, + Long popupId, + String name, // 팝업명 + PopupStatus popupStatus, + LocalDate startDate, + LocalDate endDate + +){ + public static MyPopupItemResponseDto of( + Long spaceId, String spaceName, String floor, String address, + Long popupId, String name, PopupStatus status, + LocalDate startDate, LocalDate endDate + ) { + return new MyPopupItemResponseDto( + SpaceInfoResponseDto.of(spaceId, spaceName, floor, address), + popupId, name, status, startDate, endDate + ); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/space/dto/response/SpaceInfoResponseDto.java b/src/main/java/popcong/app/application/space/dto/response/SpaceInfoResponseDto.java new file mode 100644 index 0000000..d8334a8 --- /dev/null +++ b/src/main/java/popcong/app/application/space/dto/response/SpaceInfoResponseDto.java @@ -0,0 +1,13 @@ +package popcong.app.application.space.dto.response; + +public record SpaceInfoResponseDto( + Long spaceId, + String spaceName, + String floor, + String address +) { + public static SpaceInfoResponseDto of( + Long spaceId, String spaceName,String floor ,String address) { + return new SpaceInfoResponseDto(spaceId, spaceName,floor, address); + } +} diff --git a/src/main/java/popcong/app/application/space/dto/response/SpaceReviewCountDto.java b/src/main/java/popcong/app/application/space/dto/response/SpaceReviewCountDto.java new file mode 100644 index 0000000..03ca858 --- /dev/null +++ b/src/main/java/popcong/app/application/space/dto/response/SpaceReviewCountDto.java @@ -0,0 +1,6 @@ +package popcong.app.application.space.dto.response; + +public record SpaceReviewCountDto( + +) { +} diff --git a/src/main/java/popcong/app/application/space/port/in/SpaceMapQueryUseCase.java b/src/main/java/popcong/app/application/space/port/in/SpaceMapQueryUseCase.java new file mode 100644 index 0000000..b85213c --- /dev/null +++ b/src/main/java/popcong/app/application/space/port/in/SpaceMapQueryUseCase.java @@ -0,0 +1,38 @@ +package popcong.app.application.space.port.in; + +import popcong.app.application.space.dto.response.MarkerComponentDto; +import popcong.app.domain.space.model.Space; +import popcong.app.domain.space.model.SpaceSortType; + +import java.util.List; +import java.util.Map; + +public interface SpaceMapQueryUseCase { + List getSpaceMarkersInDisplayWithFilters( + Double nwLat, + Double nwLng, + Double seLat, + Double seLng, + Integer minAmount, + Integer maxAmount, + Integer floor, + Double rating, + SpaceSortType sortType + ); + + Map getReviewCountsFor(List spaces); + + List getMarkerInDisplayWithFilters( + Double userLat, + Double userLng, + Double nwLat, + Double nwLng, + Double seLat, + Double seLng, + Integer minAmount, + Integer maxAmount, + Integer floor, + Double rating, + SpaceSortType sortType + ); +} diff --git a/src/main/java/popcong/app/application/space/port/out/ReviewCountQueryPort.java b/src/main/java/popcong/app/application/space/port/out/ReviewCountQueryPort.java new file mode 100644 index 0000000..fc95bbe --- /dev/null +++ b/src/main/java/popcong/app/application/space/port/out/ReviewCountQueryPort.java @@ -0,0 +1,12 @@ +package popcong.app.application.space.port.out; + +import java.util.Collection; +import java.util.Map; + +public interface ReviewCountQueryPort { + // 리뷰 수 구하기 + Long countBySpaceId(long spaceId); + + // 여러 공간의 리뷰 수 한 번에 구하기 + Map countBySpaceIds(Collection spaceIds); +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/space/port/out/SpaceMapQueryPort.java b/src/main/java/popcong/app/application/space/port/out/SpaceMapQueryPort.java new file mode 100644 index 0000000..1cfc91f --- /dev/null +++ b/src/main/java/popcong/app/application/space/port/out/SpaceMapQueryPort.java @@ -0,0 +1,23 @@ +package popcong.app.application.space.port.out; + +import popcong.app.domain.space.model.Space; +import popcong.app.domain.space.model.SpaceSortType; + +import java.util.List; + +// 지도 안의 마커 조건부 query 레포지토리 +public interface SpaceMapQueryPort { + List findSpacesInDisplayWithFilters( + Double nwLat, + Double nwLng, + Double seLat, + Double seLng, + Integer minAmount, + Integer maxAmount, + Integer floor, + Double rating, + SpaceSortType sortType +// Integer limit, +// Integer offset + ); +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/space/service/SpaceMapQueryService.java b/src/main/java/popcong/app/application/space/service/SpaceMapQueryService.java new file mode 100644 index 0000000..7092d13 --- /dev/null +++ b/src/main/java/popcong/app/application/space/service/SpaceMapQueryService.java @@ -0,0 +1,132 @@ +package popcong.app.application.space.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import popcong.app.adapter.out.persistence.space.mapper.SpaceMarkerMapper; +import popcong.app.application.image.port.in.GeoDistanceUseCase; +import popcong.app.application.space.dto.response.MarkerComponentDto; +import popcong.app.application.space.port.in.SpaceMapQueryUseCase; +import popcong.app.application.space.port.out.ReviewCountQueryPort; +import popcong.app.application.space.port.out.SpaceMapQueryPort; +import popcong.app.domain.space.model.Space; +import popcong.app.domain.space.model.SpaceSortType; + +import java.util.*; + +@Service +@RequiredArgsConstructor +public class SpaceMapQueryService implements SpaceMapQueryUseCase { + + private final SpaceMapQueryPort spaceMapQueryPort; + private final ReviewCountQueryPort reviewCountQueryPort; + private final GeoDistanceUseCase geoDistanceUseCase; // ← 서비스에서만 사용 + private final SpaceMarkerMapper spaceMarkerMapper; + + @Override + public List getSpaceMarkersInDisplayWithFilters( + Double nwLat, + Double nwLng, + Double seLat, + Double seLng, + Integer minAmount, + Integer maxAmount, + Integer floor, + Double rating, + SpaceSortType sortType + ) { + return spaceMapQueryPort.findSpacesInDisplayWithFilters( + nwLat, + nwLng, + seLat, + seLng, + minAmount, + maxAmount, + floor, + rating, + sortType + ); + } + + // 집계 후 Mapper에 전달하기 위함 + public Map getReviewCountsFor(List spaces) { + var ids = spaces.stream() + .map(Space::spaceId) + .toList(); + + return reviewCountQueryPort.countBySpaceIds(ids); + } + + @Override + public List getMarkerInDisplayWithFilters( + Double userLat, + Double userLng, + Double nwLat, + Double nwLng, + Double seLat, + Double seLng, + Integer minAmount, + Integer maxAmount, + Integer floor, + Double rating, + SpaceSortType sortType + ) { + // 공간 목록 조회 + List spaces = getSpaceMarkersInDisplayWithFilters( + nwLat, nwLng, seLat, seLng, + minAmount, maxAmount, floor, rating, sortType + ); + + if (spaces.isEmpty()) return List.of(); + + // 리뷰 집계 + Map reviewCounts = getReviewCountsFor(spaces); + if (spaces.isEmpty()) return List.of(); + + // 거리 계산 + Map distances = new HashMap<>(); + boolean hasUserLocation = (userLat != null && userLng != null); + for (Space s : spaces) { + Long d = hasUserLocation + ? geoDistanceUseCase.getStraightDistance(userLat, userLng, s.latitude(), s.longitude()) + : null; // 위치 없으면 null + distances.put(s.spaceId(), d); + } + + Map distancesForSort = new HashMap<>(); + for (Space s : spaces) { + Long d = distances.get(s.spaceId()); + distancesForSort.put(s.spaceId(), (d != null) ? d : Long.MAX_VALUE); + } + + // 정렬 + Comparator byIdAsc = Comparator.comparing(Space::spaceId); + Comparator byViewsDesc = Comparator.comparing(Space::views).reversed(); + Comparator byReviewsDesc = Comparator.comparing(s -> reviewCounts.getOrDefault(s.spaceId(), 0L)).reversed(); + Comparator byDistanceAsc = Comparator.comparing(s -> distancesForSort.getOrDefault(s.spaceId(), Long.MAX_VALUE)); + + List sorted = new ArrayList<>(spaces); + + SpaceSortType st = (sortType != null) ? sortType : SpaceSortType.MOST_POPULAR; + switch (st) { + case MOST_POPULAR -> sorted.sort(byViewsDesc.thenComparing(byIdAsc)); + + case NEAREST -> { + if (!hasUserLocation) { + sorted.sort(byViewsDesc.thenComparing(byIdAsc)); + } else { + sorted.sort(byDistanceAsc.thenComparing(byIdAsc)); + } + } + + case MOST_REVIEWS -> sorted.sort(byReviewsDesc.thenComparing(byDistanceAsc).thenComparing(byIdAsc)); + } + + return sorted.stream() + .map(s -> spaceMarkerMapper.toMarkerDto( + s, + distances.get(s.spaceId()), + reviewCounts.getOrDefault(s.spaceId(), 0L).intValue() + )) + .toList(); + } +} diff --git a/src/main/java/popcong/app/application/user/dto/response/HomeUserResponseDto.java b/src/main/java/popcong/app/application/user/dto/response/HomeUserResponseDto.java new file mode 100644 index 0000000..a00ce7a --- /dev/null +++ b/src/main/java/popcong/app/application/user/dto/response/HomeUserResponseDto.java @@ -0,0 +1,16 @@ +package popcong.app.application.user.dto.response; + +import popcong.app.application.space.dto.response.MyPopupItemResponseDto; +import java.util.List; + +public record HomeUserResponseDto( + UserInfoResponseDto userInfo, + List myPopups +) { + public static HomeUserResponseDto from(UserResponseDto user, List popups) { + return new HomeUserResponseDto( + UserInfoResponseDto.from(user), + popups + ); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/user/dto/response/UserInfoResponseDto.java b/src/main/java/popcong/app/application/user/dto/response/UserInfoResponseDto.java new file mode 100644 index 0000000..ffa6df7 --- /dev/null +++ b/src/main/java/popcong/app/application/user/dto/response/UserInfoResponseDto.java @@ -0,0 +1,9 @@ +package popcong.app.application.user.dto.response; + +public record UserInfoResponseDto( + String name +) { + public static UserInfoResponseDto from(UserResponseDto user) { + return new UserInfoResponseDto(user.name()); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/user/dto/response/UserResponseDto.java b/src/main/java/popcong/app/application/user/dto/response/UserResponseDto.java new file mode 100644 index 0000000..8aac2f3 --- /dev/null +++ b/src/main/java/popcong/app/application/user/dto/response/UserResponseDto.java @@ -0,0 +1,38 @@ +package popcong.app.application.user.dto.response; + +import popcong.app.domain.user.model.Provider; +import popcong.app.domain.user.model.Role; +import popcong.app.domain.user.model.User; +import popcong.app.domain.user.model.UserRole; + +import java.time.LocalDateTime; + +public record UserResponseDto( + Long userId, + Provider provider, + String providerId, + String email, + String name, + String profileImageUrl, + String introduction, + Role role, + UserRole userRole, + LocalDateTime createdAt, + LocalDateTime deletedAt +) { + public static UserResponseDto from(User user) { + return new UserResponseDto( + user.userId(), + user.provider(), + user.providerId(), + user.email(), + user.name(), + user.profileImageUrl(), + user.introduction(), + user.role(), + user.userRole(), + user.createdAt(), + user.deletedAt() + ); + } +} diff --git a/src/main/java/popcong/app/application/user/port/in/GetHomeInfoUseCase.java b/src/main/java/popcong/app/application/user/port/in/GetHomeInfoUseCase.java new file mode 100644 index 0000000..df9578c --- /dev/null +++ b/src/main/java/popcong/app/application/user/port/in/GetHomeInfoUseCase.java @@ -0,0 +1,7 @@ +package popcong.app.application.user.port.in; + +import popcong.app.application.user.dto.response.HomeUserResponseDto; + +public interface GetHomeInfoUseCase { + HomeUserResponseDto getMyHomeInfo(Long userId); +} diff --git a/src/main/java/popcong/app/application/user/port/in/UserInfoUseCase.java b/src/main/java/popcong/app/application/user/port/in/UserInfoUseCase.java new file mode 100644 index 0000000..a364c21 --- /dev/null +++ b/src/main/java/popcong/app/application/user/port/in/UserInfoUseCase.java @@ -0,0 +1,10 @@ +package popcong.app.application.user.port.in; + +import popcong.app.domain.user.model.User; + +public interface UserInfoUseCase { + User updateUserRoleToGeneral(Long userId); + User updateUserRoleToGuest(Long userId); + User updateUserRoleToPending(Long userId); + User updateUserRoleToHost(Long userId); +} diff --git a/src/main/java/popcong/app/application/user/port/out/LoadMyPopupsPort.java b/src/main/java/popcong/app/application/user/port/out/LoadMyPopupsPort.java new file mode 100644 index 0000000..5a4a28f --- /dev/null +++ b/src/main/java/popcong/app/application/user/port/out/LoadMyPopupsPort.java @@ -0,0 +1,9 @@ +package popcong.app.application.user.port.out; + +import popcong.app.application.space.dto.response.MyPopupItemResponseDto; + +import java.util.List; + +public interface LoadMyPopupsPort { + List findMyPopups(Long userId); +} diff --git a/src/main/java/popcong/app/application/user/port/out/LoadUserPort.java b/src/main/java/popcong/app/application/user/port/out/LoadUserPort.java new file mode 100644 index 0000000..65b3c7e --- /dev/null +++ b/src/main/java/popcong/app/application/user/port/out/LoadUserPort.java @@ -0,0 +1,10 @@ +package popcong.app.application.user.port.out; + +import popcong.app.domain.user.model.User; + +import java.util.Optional; + +public interface LoadUserPort { + Optional loadUserByEmail(String email); + Optional loadUserById(Long id); +} \ No newline at end of file diff --git a/src/main/java/popcong/app/application/user/port/out/SaveUserPort.java b/src/main/java/popcong/app/application/user/port/out/SaveUserPort.java new file mode 100644 index 0000000..9bd66ad --- /dev/null +++ b/src/main/java/popcong/app/application/user/port/out/SaveUserPort.java @@ -0,0 +1,7 @@ +package popcong.app.application.user.port.out; + +import popcong.app.domain.user.model.User; + +public interface SaveUserPort { + User saveUser(User user); +} diff --git a/src/main/java/popcong/app/application/user/service/HomeInfoService.java b/src/main/java/popcong/app/application/user/service/HomeInfoService.java new file mode 100644 index 0000000..7b336c4 --- /dev/null +++ b/src/main/java/popcong/app/application/user/service/HomeInfoService.java @@ -0,0 +1,42 @@ +package popcong.app.application.user.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import org.springframework.transaction.annotation.Transactional; +import popcong.app.application.user.dto.response.HomeUserResponseDto; +import popcong.app.application.user.dto.response.UserResponseDto; +import popcong.app.application.user.port.in.GetHomeInfoUseCase; +import popcong.app.application.user.port.out.LoadMyPopupsPort; +import popcong.app.application.user.port.out.LoadUserPort; +import popcong.app.application.space.dto.response.MyPopupItemResponseDto; +import popcong.app.domain.user.model.User; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.AuthErrorCode; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class HomeInfoService implements GetHomeInfoUseCase { + + private final LoadUserPort loadUserPort; + private final LoadMyPopupsPort loadMyPopupsPort; + + @Override + @Transactional(readOnly = true) + public HomeUserResponseDto getMyHomeInfo(Long userId) { + // 사용자 조회 (없으면 예외) + User user = loadUserPort.loadUserById(userId) + .orElseThrow(() -> new BusinessException(AuthErrorCode.UNAUTHORIZED)); + + // 내 팝업 조회 + List myPopups = + loadMyPopupsPort.findMyPopups(userId); + + // 응답 DTO 조합 + return HomeUserResponseDto.from(UserResponseDto.from(user), myPopups); + } + +} + diff --git a/src/main/java/popcong/app/application/user/service/UserInfoService.java b/src/main/java/popcong/app/application/user/service/UserInfoService.java new file mode 100644 index 0000000..b2d2ddc --- /dev/null +++ b/src/main/java/popcong/app/application/user/service/UserInfoService.java @@ -0,0 +1,53 @@ +package popcong.app.application.user.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import popcong.app.application.user.port.in.UserInfoUseCase; +import popcong.app.application.user.port.out.LoadUserPort; +import popcong.app.application.user.port.out.SaveUserPort; +import popcong.app.domain.user.model.User; +import popcong.app.domain.user.model.UserRole; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.AuthErrorCode; + +@Service +@Transactional +@RequiredArgsConstructor +public class UserInfoService implements UserInfoUseCase { + + private final LoadUserPort loadUserPort; + private final SaveUserPort saveUserPort; + + // Guest 유저로 복귀 + public User updateUserRoleToGuest(Long userId) { + User user = loadUserPort.loadUserById(userId) + .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); + User updated = user.withUserRole(UserRole.GUEST); + return saveUserPort.saveUser(updated); + }; + + // 일반 유저로 변경 + public User updateUserRoleToGeneral(Long userId) { + User user = loadUserPort.loadUserById(userId) + .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); + User updated = user.withUserRole(UserRole.GENERAL); + return saveUserPort.saveUser(updated); + }; + + // 호스트 인증 대기로 변경 + public User updateUserRoleToPending(Long userId) { + User user = loadUserPort.loadUserById(userId) + .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); + User updated = user.withUserRole(UserRole.PENDING); + return saveUserPort.saveUser(updated); + }; + + // Host 유저로 변경 + public User updateUserRoleToHost(Long userId) { + User user = loadUserPort.loadUserById(userId) + .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); + User updated = user.withUserRole(UserRole.HOST); + return saveUserPort.saveUser(updated); + }; +} diff --git a/src/main/java/popcong/app/domain/auth/model/AuthInfo.java b/src/main/java/popcong/app/domain/auth/model/AuthInfo.java new file mode 100644 index 0000000..7ef658f --- /dev/null +++ b/src/main/java/popcong/app/domain/auth/model/AuthInfo.java @@ -0,0 +1,18 @@ +package popcong.app.domain.auth.model; + +import popcong.app.domain.user.model.Role; +import popcong.app.domain.user.model.User; + +public record AuthInfo( + Long id, + String email, + Role role +) { + public static AuthInfo from(User user) { + return new AuthInfo( + user.userId(), + user.email(), + user.role() + ); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/domain/auth/model/OAuth2SignInCommand.java b/src/main/java/popcong/app/domain/auth/model/OAuth2SignInCommand.java new file mode 100644 index 0000000..13543d8 --- /dev/null +++ b/src/main/java/popcong/app/domain/auth/model/OAuth2SignInCommand.java @@ -0,0 +1,9 @@ +package popcong.app.domain.auth.model; + +import popcong.app.domain.user.model.Provider; + +public record OAuth2SignInCommand( + Provider provider, + String providerId, + String email +) {} \ No newline at end of file diff --git a/src/main/java/popcong/app/domain/auth/model/SignUpCommand.java b/src/main/java/popcong/app/domain/auth/model/SignUpCommand.java new file mode 100644 index 0000000..ff9e3e2 --- /dev/null +++ b/src/main/java/popcong/app/domain/auth/model/SignUpCommand.java @@ -0,0 +1,8 @@ +package popcong.app.domain.auth.model; + +public record SignUpCommand ( + String name, + String introduction, + String profileImageUrl, + Boolean isUploaded +) {} \ No newline at end of file diff --git a/src/main/java/popcong/app/domain/chat/model/Chat.java b/src/main/java/popcong/app/domain/chat/model/Chat.java new file mode 100644 index 0000000..e997e55 --- /dev/null +++ b/src/main/java/popcong/app/domain/chat/model/Chat.java @@ -0,0 +1,14 @@ +package popcong.app.domain.chat.model; + +import java.time.LocalDateTime; + +public record Chat( + Long chatId, + Long guestId, + Long hostId, + Long spaceId, + LocalDateTime createdAt, + LocalDateTime lastMessageAt, + LocalDateTime lastReadAt +) { +} diff --git a/src/main/java/popcong/app/domain/image/model/Image.java b/src/main/java/popcong/app/domain/image/model/Image.java new file mode 100644 index 0000000..41dff5c --- /dev/null +++ b/src/main/java/popcong/app/domain/image/model/Image.java @@ -0,0 +1,13 @@ +package popcong.app.domain.image.model; + +import java.time.LocalDateTime; + +public record Image( + Long imageId, + String imageUrl, + Integer saveOrder,//저장순서 + Long imageableId, //이미지가 연결된 대상의 ID + ImageableType imageableType, //이미지가 연결된 테이블 타입 + LocalDateTime createdAt //저장일시 +) { +} diff --git a/src/main/java/popcong/app/domain/image/model/ImageableType.java b/src/main/java/popcong/app/domain/image/model/ImageableType.java new file mode 100644 index 0000000..6ce17b0 --- /dev/null +++ b/src/main/java/popcong/app/domain/image/model/ImageableType.java @@ -0,0 +1,7 @@ +package popcong.app.domain.image.model; + +public enum ImageableType { + PROFILE, + SPACE, + POPUP +} diff --git a/src/main/java/popcong/app/domain/space/model/Popup.java b/src/main/java/popcong/app/domain/space/model/Popup.java new file mode 100644 index 0000000..11836c5 --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/Popup.java @@ -0,0 +1,17 @@ +package popcong.app.domain.space.model; + +import java.time.LocalDateTime; + +public record Popup( + Long popupId, + Long reservationId, + String name, + PopupStatus popupStatus, + String address, + PopupType popupType, + Integer price, + LocalDateTime startDate, + LocalDateTime endDate, + Integer views +) { +} diff --git a/src/main/java/popcong/app/domain/space/model/PopupStatus.java b/src/main/java/popcong/app/domain/space/model/PopupStatus.java new file mode 100644 index 0000000..bb48584 --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/PopupStatus.java @@ -0,0 +1,9 @@ +package popcong.app.domain.space.model; + +public enum PopupStatus { + PREPARING, // 시작 전 + IN_PROGRESS, // 진행중 + HOLIDAY, // 휴무 + DONE, // 종료 + CANCELED // 취소 +} diff --git a/src/main/java/popcong/app/domain/space/model/PopupType.java b/src/main/java/popcong/app/domain/space/model/PopupType.java new file mode 100644 index 0000000..096e3e4 --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/PopupType.java @@ -0,0 +1,9 @@ +package popcong.app.domain.space.model; + +public enum PopupType { + POPUP_STORE, // 팝업스토어 + EXHIBITION, // 전시 + PERFORMANCE, // 공연 + EVENT, // 행사 + EDUCATION // 교육 +} diff --git a/src/main/java/popcong/app/domain/space/model/Reservation.java b/src/main/java/popcong/app/domain/space/model/Reservation.java new file mode 100644 index 0000000..b6845cf --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/Reservation.java @@ -0,0 +1,17 @@ +package popcong.app.domain.space.model; + +import java.time.LocalDateTime; + +public record Reservation( + Long reservationId, + Long userId, + Long spaceId, + LocalDateTime reservationDate, //예약 확정 날짜 + LocalDateTime startTime, //사용 시작 날짜 + LocalDateTime endTime, //사용 종료 날짜 + ReservationStatus status, + Integer totalRentalFee, //대여료 + Integer Deposit //보증금 + +) { +} diff --git a/src/main/java/popcong/app/domain/space/model/ReservationReview.java b/src/main/java/popcong/app/domain/space/model/ReservationReview.java new file mode 100644 index 0000000..92d7b5c --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/ReservationReview.java @@ -0,0 +1,12 @@ +package popcong.app.domain.space.model; + +import java.time.LocalDateTime; + +public record ReservationReview( + Long reservationReviewId, + Long reservationId, + Double rating, + String description, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/popcong/app/domain/space/model/ReservationStatus.java b/src/main/java/popcong/app/domain/space/model/ReservationStatus.java new file mode 100644 index 0000000..c75f2ea --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/ReservationStatus.java @@ -0,0 +1,8 @@ +package popcong.app.domain.space.model; + +public enum ReservationStatus { + BOOKING, // 예약중 + IN_PROGRESS, // 진행중 + DONE, // 완료 + CANCELLED, // 취소 +} diff --git a/src/main/java/popcong/app/domain/space/model/Space.java b/src/main/java/popcong/app/domain/space/model/Space.java new file mode 100644 index 0000000..235bce3 --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/Space.java @@ -0,0 +1,24 @@ +package popcong.app.domain.space.model; + +public record Space( + Long spaceId, + Long userId, + String spaceName, + SpaceType spaceType, + SpaceApplicationType spaceApplicationType, + PopupType spaceRentalType, + Integer imageCount, + String description, + Integer deposit, + Integer rentalFee, + Integer floor, + Double area, + Double rating, + String address, + String location, + Double latitude, + Double longitude, + Status status, + Integer views +) { +} diff --git a/src/main/java/popcong/app/domain/space/model/SpaceApplicationType.java b/src/main/java/popcong/app/domain/space/model/SpaceApplicationType.java new file mode 100644 index 0000000..85f3223 --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/SpaceApplicationType.java @@ -0,0 +1,6 @@ +package popcong.app.domain.space.model; + +public enum SpaceApplicationType { + COMMERCIAL, // 근린생활시설 + FACILITIES // 상업시설 +} diff --git a/src/main/java/popcong/app/domain/space/model/SpaceSortType.java b/src/main/java/popcong/app/domain/space/model/SpaceSortType.java new file mode 100644 index 0000000..67914f1 --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/SpaceSortType.java @@ -0,0 +1,7 @@ +package popcong.app.domain.space.model; + +public enum SpaceSortType { + MOST_POPULAR, + NEAREST, + MOST_REVIEWS +} diff --git a/src/main/java/popcong/app/domain/space/model/SpaceType.java b/src/main/java/popcong/app/domain/space/model/SpaceType.java new file mode 100644 index 0000000..69ed4ee --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/SpaceType.java @@ -0,0 +1,8 @@ +package popcong.app.domain.space.model; + +public enum SpaceType { + BUILDING, // 상가 + WHOLE, // 단독 + PLACE // 부지 + +} diff --git a/src/main/java/popcong/app/domain/space/model/Status.java b/src/main/java/popcong/app/domain/space/model/Status.java new file mode 100644 index 0000000..4870ae9 --- /dev/null +++ b/src/main/java/popcong/app/domain/space/model/Status.java @@ -0,0 +1,6 @@ +package popcong.app.domain.space.model; + +public enum Status { + AVAILABLE, + RESERVED +} diff --git a/src/main/java/popcong/app/domain/user/model/DocumentType.java b/src/main/java/popcong/app/domain/user/model/DocumentType.java new file mode 100644 index 0000000..4bc9ac1 --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/DocumentType.java @@ -0,0 +1,5 @@ +package popcong.app.domain.user.model; + +public enum DocumentType { + COPY_OF_IDENTIFICATION, BUSINESS_LICENSE, BUILDING_REGISTER, LEASE_AGREEMENT, COPY_OF_BANKBOOK +} diff --git a/src/main/java/popcong/app/domain/user/model/Notification.java b/src/main/java/popcong/app/domain/user/model/Notification.java new file mode 100644 index 0000000..f4bf718 --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/Notification.java @@ -0,0 +1,14 @@ +package popcong.app.domain.user.model; + +import java.time.LocalDateTime; + +public record Notification( + Long notificationId, + Long userId, + NotificationType notificationType, + String message, + String url, + boolean isRead, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/popcong/app/domain/user/model/NotificationType.java b/src/main/java/popcong/app/domain/user/model/NotificationType.java new file mode 100644 index 0000000..a727dcf --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/NotificationType.java @@ -0,0 +1,8 @@ +package popcong.app.domain.user.model; + +public enum NotificationType { + CHAT, + RESERVATION, + NORMAL, + REVIEW +} diff --git a/src/main/java/popcong/app/domain/user/model/Provider.java b/src/main/java/popcong/app/domain/user/model/Provider.java new file mode 100644 index 0000000..803cb72 --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/Provider.java @@ -0,0 +1,5 @@ +package popcong.app.domain.user.model; + +public enum Provider { + kakao +} diff --git a/src/main/java/popcong/app/domain/user/model/Role.java b/src/main/java/popcong/app/domain/user/model/Role.java new file mode 100644 index 0000000..87364d9 --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/Role.java @@ -0,0 +1,6 @@ +package popcong.app.domain.user.model; + +public enum Role { + ROLE_USER, + ROLE_ADMIN +} diff --git a/src/main/java/popcong/app/domain/user/model/SignUpUserType.java b/src/main/java/popcong/app/domain/user/model/SignUpUserType.java new file mode 100644 index 0000000..eb11201 --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/SignUpUserType.java @@ -0,0 +1,5 @@ +package popcong.app.domain.user.model; + +public enum SignUpUserType { + GUEST, HOST +} diff --git a/src/main/java/popcong/app/domain/user/model/UploadItem.java b/src/main/java/popcong/app/domain/user/model/UploadItem.java new file mode 100644 index 0000000..2a1e8f3 --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/UploadItem.java @@ -0,0 +1,8 @@ +package popcong.app.domain.user.model; + +import org.springframework.web.multipart.MultipartFile; + +public record UploadItem( + DocumentType type, + MultipartFile file +) {} diff --git a/src/main/java/popcong/app/domain/user/model/User.java b/src/main/java/popcong/app/domain/user/model/User.java new file mode 100644 index 0000000..230a450 --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/User.java @@ -0,0 +1,33 @@ +package popcong.app.domain.user.model; + +import java.time.LocalDateTime; + +public record User ( + Long userId, + Provider provider, // 소셜로그인 제공자 + String providerId, // 소셜로그인에서 제공받은 ID + String email, // 소셜로그인 email + String name, + String profileImageUrl, + String introduction, + Role role, // user-admin + UserRole userRole, // guest-host + LocalDateTime createdAt, + LocalDateTime deletedAt +) { + public User withUserRole(UserRole userRole) { + return new User( + this.userId, + this.provider, + this.providerId, + this.email, + this.name, + this.profileImageUrl, + this.introduction, + this.role, + userRole, + this.createdAt, + this.deletedAt + ); + } +} diff --git a/src/main/java/popcong/app/domain/user/model/UserRole.java b/src/main/java/popcong/app/domain/user/model/UserRole.java new file mode 100644 index 0000000..639a8b1 --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/UserRole.java @@ -0,0 +1,8 @@ +package popcong.app.domain.user.model; + +public enum UserRole { + GUEST, // 회원 정보가 등록되지 않은 사용자 + GENERAL, // 일반 사용자 (임차인) + PENDING, // 호스트 신청 서류 제출자 + HOST // 호스트 (임대인) +} diff --git a/src/main/java/popcong/app/domain/user/model/Wishlist.java b/src/main/java/popcong/app/domain/user/model/Wishlist.java new file mode 100644 index 0000000..4a221b1 --- /dev/null +++ b/src/main/java/popcong/app/domain/user/model/Wishlist.java @@ -0,0 +1,11 @@ +package popcong.app.domain.user.model; + +import java.time.LocalDateTime; + +public record Wishlist( + Long wishlistId, + Long userId, + Long spaceId, + LocalDateTime createdAt //위시생성일 +) { +} diff --git a/src/main/java/popcong/app/global/dto/ResponseDto.java b/src/main/java/popcong/app/global/dto/ResponseDto.java new file mode 100644 index 0000000..83aa152 --- /dev/null +++ b/src/main/java/popcong/app/global/dto/ResponseDto.java @@ -0,0 +1,34 @@ +package popcong.app.global.dto; + +import org.springframework.http.HttpStatusCode; + +public record ResponseDto( + int statusCode, + String message, + T data +) { + // data -> X + public static ResponseDto success( + final HttpStatusCode statusCode, + final String message + ) { + return new ResponseDto<>( + statusCode.value(), + message, + null + ); + } + + // data -> O + public static ResponseDto success( + final HttpStatusCode statusCode, + final String message, + final T data + ) { + return new ResponseDto<>( + statusCode.value(), + message, + data + ); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/global/exception/ErrorCode.java b/src/main/java/popcong/app/global/exception/ErrorCode.java new file mode 100644 index 0000000..90253d4 --- /dev/null +++ b/src/main/java/popcong/app/global/exception/ErrorCode.java @@ -0,0 +1,22 @@ +package popcong.app.global.exception; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + int getHttpStatus(); + String getCode(); + String getMessage(); + + default HttpStatus getHttpStatusEnum() { + return HttpStatus.valueOf(getHttpStatus()); + } + + default void validate() { + if (getHttpStatus() < 100 || getHttpStatus() > 599) { + throw new IllegalArgumentException("Invalid HTTP status: " + getHttpStatus()); + } + if (getCode() == null || getCode().trim().isEmpty()) { + throw new IllegalArgumentException("Error code cannot be null or empty"); + } + } +} diff --git a/src/main/java/popcong/app/global/exception/ErrorResponse.java b/src/main/java/popcong/app/global/exception/ErrorResponse.java new file mode 100644 index 0000000..08c46d5 --- /dev/null +++ b/src/main/java/popcong/app/global/exception/ErrorResponse.java @@ -0,0 +1,59 @@ +package popcong.app.global.exception; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.validation.BindingResult; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import popcong.app.global.exception.error.CommonErrorCode; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ErrorResponse { + + private int httpStatus; + private String code; + private String message; + private final LocalDateTime timestamp = LocalDateTime.now(); + private List fieldErrors; + + private ErrorResponse(final ErrorCode errorCode) { + errorCode.validate(); + this.httpStatus = errorCode.getHttpStatus(); + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + this.fieldErrors = new ArrayList<>(); + } + + private ErrorResponse(final ErrorCode errorCode, final List fieldErrors) { + errorCode.validate(); + this.httpStatus = errorCode.getHttpStatus(); + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + this.fieldErrors = fieldErrors != null ? new ArrayList<>(fieldErrors) : new ArrayList<>(); + } + + public static ErrorResponse of(final ErrorCode errorCode) { + return new ErrorResponse(errorCode); + } + + public static ErrorResponse of(final ErrorCode errorCode, final List fieldErrors) { + return new ErrorResponse(errorCode, fieldErrors); + } + + public static ErrorResponse of(final ErrorCode errorCode, final BindingResult bindingResult) { + return new ErrorResponse(errorCode, FieldError.of(bindingResult)); + } + + public static ErrorResponse of(MethodArgumentTypeMismatchException e) { + final String value = e.getValue() == null ? "" : e.getValue().toString(); + final List fieldErrors = new ArrayList<>(); + return new ErrorResponse(CommonErrorCode.INVALID_INPUT_VALUE, fieldErrors); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/global/exception/ExceptionHandlerAdvice.java b/src/main/java/popcong/app/global/exception/ExceptionHandlerAdvice.java new file mode 100644 index 0000000..4751d09 --- /dev/null +++ b/src/main/java/popcong/app/global/exception/ExceptionHandlerAdvice.java @@ -0,0 +1,90 @@ +package popcong.app.global.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import popcong.app.global.exception.custom.BusinessException; +import popcong.app.global.exception.error.CommonErrorCode; + +@Slf4j +@RestControllerAdvice +public class ExceptionHandlerAdvice { + + private ResponseEntity createErrorResponse(ErrorCode errorCode) { + final ErrorResponse response = ErrorResponse.of(errorCode); + return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getHttpStatus())); + } + + private ResponseEntity createErrorResponse(ErrorResponse errorResponse) { + return new ResponseEntity<>(errorResponse, HttpStatus.valueOf(errorResponse.getHttpStatus())); + } + + // 📝 business logic error exception + @ExceptionHandler(BusinessException.class) + protected ResponseEntity handleBusinessException( + BusinessException e + ) { + log.warn("handleInternalLogicException(BusinessException)", e); + final ErrorCode code = e.getErrorCode(); + return createErrorResponse(code); + } + + + // binding error exception caused by @Valid or @Validated + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException e + ) { + log.warn("handleMethodArgumentNotValidException", e); + final ErrorResponse response = ErrorResponse.of( + CommonErrorCode.INVALID_INPUT_VALUE, + e.getBindingResult() + ); + return createErrorResponse(response); + } + + + // error exception when binding fails due to mismatch in enum type + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + protected ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException e + ) { + log.warn("handleMethodArgumentTypeMismatchException", e); + final ErrorResponse response = ErrorResponse.of(e); + return createErrorResponse(response); + } + + + // error exception when using an unsupported HTTP method + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + protected ResponseEntity handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException e + ) { + log.warn("handleHttpRequestMethodNotSupportedException", e); + return createErrorResponse(CommonErrorCode.METHOD_NOT_ALLOWED); + } + + + @ExceptionHandler(IllegalArgumentException.class) + protected ResponseEntity handleIllegalArgumentException( + IllegalArgumentException e + ) { + log.warn("handleIllegalArgumentException", e); + return createErrorResponse(CommonErrorCode.INVALID_INPUT_VALUE); + } + + + // rest error exception ... + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException( + Exception e + ) { + log.error("handleException - Unexpected error occurred", e); + return createErrorResponse(CommonErrorCode.INTERNAL_SERVER_ERROR); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/global/exception/FieldError.java b/src/main/java/popcong/app/global/exception/FieldError.java new file mode 100644 index 0000000..d4cd8ff --- /dev/null +++ b/src/main/java/popcong/app/global/exception/FieldError.java @@ -0,0 +1,54 @@ +package popcong.app.global.exception; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.validation.BindingResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class FieldError { + + private String field; + private String value; + private String reason; + + public static List of( + final String field, + final String value, + final String reason + ) { + List fieldErrors = new ArrayList<>(); + fieldErrors.add(new FieldError( + field != null ? field : "unknown", + value != null ? value : "null", + reason != null ? reason : "validation failed" + )); + return fieldErrors; + } + + public static List of( + final BindingResult bindingResult + ) { + if (bindingResult == null || bindingResult.hasFieldErrors()) { + return new ArrayList<>(); + } + + final List fieldErrors = bindingResult.getFieldErrors(); + + return fieldErrors + .stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage() != null ? error.getDefaultMessage() : "validation failed" + )) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/global/exception/custom/BusinessException.java b/src/main/java/popcong/app/global/exception/custom/BusinessException.java new file mode 100644 index 0000000..6f56f4b --- /dev/null +++ b/src/main/java/popcong/app/global/exception/custom/BusinessException.java @@ -0,0 +1,39 @@ +package popcong.app.global.exception.custom; + +import lombok.Getter; +import org.springframework.http.HttpStatus; +import popcong.app.global.exception.ErrorCode; + +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + public BusinessException(String message, ErrorCode errorCode) { + super(message); + if (errorCode == null) { + throw new IllegalArgumentException("errorCode cannot be null"); + } + errorCode.validate(); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode) { + super(errorCode != null ? errorCode.getMessage() : "Business Exception"); + + if (errorCode == null) { + throw new IllegalArgumentException("errorCode cannot be null"); + } + + errorCode.validate(); + this.errorCode = errorCode; + } + + public int getStatusCode() { + return errorCode.getHttpStatus(); + } + + public HttpStatus getHttpStatus() { + return errorCode.getHttpStatusEnum(); + } +} diff --git a/src/main/java/popcong/app/global/exception/error/AuthErrorCode.java b/src/main/java/popcong/app/global/exception/error/AuthErrorCode.java new file mode 100644 index 0000000..30aebb0 --- /dev/null +++ b/src/main/java/popcong/app/global/exception/error/AuthErrorCode.java @@ -0,0 +1,30 @@ +package popcong.app.global.exception.error; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import popcong.app.global.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.OBJECT) +public enum AuthErrorCode implements ErrorCode { + + // 400 + UNSUPPORTED_SOCIAL_LOGIN(HttpStatus.BAD_REQUEST.value(), "A4001", "지원하지 않는 소셜 로그인입니다."), + INVALID_KAKAO_RESPONSE(HttpStatus.BAD_REQUEST.value(), "A4002", "카카오 응답 형식이 유효하지 않습니다."), + + // 401 + UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(),"A4011", "인증되지 않은 사용자압니다."), + + // 403 + ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "A4031", "접근 권한이 없습니다."), + + // 404 + USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "A4041", "사용자를 찾을 수 없습니다."),; + + private final int httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/popcong/app/global/exception/error/CommonErrorCode.java b/src/main/java/popcong/app/global/exception/error/CommonErrorCode.java new file mode 100644 index 0000000..7765734 --- /dev/null +++ b/src/main/java/popcong/app/global/exception/error/CommonErrorCode.java @@ -0,0 +1,28 @@ +package popcong.app.global.exception.error; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import popcong.app.global.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.OBJECT) +public enum CommonErrorCode implements ErrorCode { + + // 400 + INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST.value(), "C4001", "Invalid Type Value"), + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST.value(), "C4002", "Invalid Input Value"), + ENTITY_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "C4003", "Entity Not Found"), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "C4004", "Invalid Http Method"), + NO_REQUIRED_FILES(HttpStatus.BAD_REQUEST.value(), "C4005", "필수 제출 파일이 누락되었습니다."), + + // 5XX + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "C5001", "Internal Server Error"); + + + private final int httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/popcong/app/global/exception/error/ImageS3ErrorCode.java b/src/main/java/popcong/app/global/exception/error/ImageS3ErrorCode.java new file mode 100644 index 0000000..a7ad708 --- /dev/null +++ b/src/main/java/popcong/app/global/exception/error/ImageS3ErrorCode.java @@ -0,0 +1,29 @@ +package popcong.app.global.exception.error; + + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import popcong.app.global.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.OBJECT) +public enum ImageS3ErrorCode implements ErrorCode { + + //4xx + FILE_NOT_FOUND (HttpStatus.NOT_FOUND.value(), "IMG4040", "요청한 파일을 찾을 수 없습니다."), + FILE_TOO_LARGE (HttpStatus.PAYLOAD_TOO_LARGE.value(), "IMG4130", "파일 크기 제한을 초과했습니다."), + STORAGE_ACCESS_DENIED (HttpStatus.FORBIDDEN.value(), "IMG4030", "스토리지 접근 권한이 없습니다."), + INVALID_CONTENT_TYPE (HttpStatus.BAD_REQUEST.value(), "IMG4001", "지원하지 않는 콘텐츠 타입입니다."), + + // 5xx + FILE_UPLOAD_ERROR (HttpStatus.INTERNAL_SERVER_ERROR.value(),"IMG5001", "파일 업로드 중 오류가 발생했습니다."), + FILE_DOWNLOAD_ERROR (HttpStatus.INTERNAL_SERVER_ERROR.value(),"IMG5002", "파일 다운로드 중 오류가 발생했습니다."), + FILE_DELETE_ERROR (HttpStatus.INTERNAL_SERVER_ERROR.value(),"IMG5003", "파일 삭제 중 오류가 발생했습니다."); + + private final int httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/popcong/app/global/exception/error/SpaceErrorCode.java b/src/main/java/popcong/app/global/exception/error/SpaceErrorCode.java new file mode 100644 index 0000000..3d4340b --- /dev/null +++ b/src/main/java/popcong/app/global/exception/error/SpaceErrorCode.java @@ -0,0 +1,23 @@ +package popcong.app.global.exception.error; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import popcong.app.global.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.OBJECT) +public enum SpaceErrorCode implements ErrorCode { + + // 400 + INVALID_PRICE_ERROR(HttpStatus.BAD_REQUEST.value(), "S4001", "금액 범위가 잘못되었습니다."), + UNSUPPORTED_SORT_TYPE(HttpStatus.BAD_REQUEST.value(), "S4002", "지원하지 않는 정렬 타입입니다."), + INVALID_BOUDING_BOX(HttpStatus.BAD_REQUEST.value(), "S4003", "탐색 범위가 잘못됐습니다."), + USER_LOCATION_REQUIRED(HttpStatus.BAD_REQUEST.value(), "S4004", "사용자의 현재 위치 정보가 필요합니다."); + + private final int httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/popcong/app/infra/config/cors/CorsConfig.java b/src/main/java/popcong/app/infra/config/cors/CorsConfig.java new file mode 100644 index 0000000..1cc6dd8 --- /dev/null +++ b/src/main/java/popcong/app/infra/config/cors/CorsConfig.java @@ -0,0 +1,25 @@ +package popcong.app.infra.config.cors; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@EnableConfigurationProperties(CorsProperties.class) +@RequiredArgsConstructor +public class CorsConfig implements WebMvcConfigurer { + + private final CorsProperties corsProperties; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(corsProperties.getAllowedOrigins().toArray(new String[0])) + .allowedMethods(corsProperties.getAllowedMethods().toArray(new String[0])) + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/infra/config/cors/CorsProperties.java b/src/main/java/popcong/app/infra/config/cors/CorsProperties.java new file mode 100644 index 0000000..4e3cf54 --- /dev/null +++ b/src/main/java/popcong/app/infra/config/cors/CorsProperties.java @@ -0,0 +1,26 @@ +package popcong.app.infra.config.cors; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "cors") +public class CorsProperties { + + /** + * 허용된 Origins + */ + private List allowedOrigins = new ArrayList<>(); + + /** + * 허용된 HTTP Methods + */ + private List allowedMethods = new ArrayList<>(); +} \ No newline at end of file diff --git a/src/main/java/popcong/app/infra/config/gcp/GcpApiConfig.java b/src/main/java/popcong/app/infra/config/gcp/GcpApiConfig.java new file mode 100644 index 0000000..3f18a50 --- /dev/null +++ b/src/main/java/popcong/app/infra/config/gcp/GcpApiConfig.java @@ -0,0 +1,41 @@ +package popcong.app.infra.config.gcp; + +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.ServiceAccountCredentials; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class GcpApiConfig { + + private final GcpApiProperties gcpApiProperties; + + @Bean + public Drive googleDrive() throws IOException, GeneralSecurityException { + var gsonFactory = GsonFactory.getDefaultInstance(); + + var credentials = ServiceAccountCredentials.fromStream( + new ClassPathResource("popcong-c9addd5f509d.json") + .getInputStream() + ).createScoped( + List.of(DriveScopes.DRIVE_FILE, DriveScopes.DRIVE_METADATA) + ); + + return new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + gsonFactory, + new HttpCredentialsAdapter(credentials) + ).setApplicationName("popcong").build(); + } +} diff --git a/src/main/java/popcong/app/infra/config/gcp/GcpApiProperties.java b/src/main/java/popcong/app/infra/config/gcp/GcpApiProperties.java new file mode 100644 index 0000000..2a23614 --- /dev/null +++ b/src/main/java/popcong/app/infra/config/gcp/GcpApiProperties.java @@ -0,0 +1,20 @@ +package popcong.app.infra.config.gcp; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "google") +public class GcpApiProperties { + + private String hostDriveId; + private String guestDriveId; + + private String hostRootDriveName; + private String guestRootDriveName; +} diff --git a/src/main/java/popcong/app/infra/config/jpa/QuerydslConfig.java b/src/main/java/popcong/app/infra/config/jpa/QuerydslConfig.java new file mode 100644 index 0000000..8bd346a --- /dev/null +++ b/src/main/java/popcong/app/infra/config/jpa/QuerydslConfig.java @@ -0,0 +1,15 @@ +package popcong.app.infra.config.jpa; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/infra/config/jwt/JwtConfig.java b/src/main/java/popcong/app/infra/config/jwt/JwtConfig.java new file mode 100644 index 0000000..d133b70 --- /dev/null +++ b/src/main/java/popcong/app/infra/config/jwt/JwtConfig.java @@ -0,0 +1,22 @@ +package popcong.app.infra.config.jwt; + +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.crypto.SecretKey; + +@Configuration +@RequiredArgsConstructor +public class JwtConfig { + + private final JwtProperties jwtProperties; + + @Bean + public SecretKey secretKey() { + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecret()); + return Keys.hmacShaKeyFor(keyBytes); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/infra/config/jwt/JwtProperties.java b/src/main/java/popcong/app/infra/config/jwt/JwtProperties.java new file mode 100644 index 0000000..7547d92 --- /dev/null +++ b/src/main/java/popcong/app/infra/config/jwt/JwtProperties.java @@ -0,0 +1,23 @@ +package popcong.app.infra.config.jwt; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + + private String secret; + private ExpirationTime expirationTime; + + @Getter + @Setter + public static class ExpirationTime { + private Long accessToken; + private Long refreshToken; + } +} diff --git a/src/main/java/popcong/app/infra/config/s3/S3Config.java b/src/main/java/popcong/app/infra/config/s3/S3Config.java new file mode 100644 index 0000000..313c2ab --- /dev/null +++ b/src/main/java/popcong/app/infra/config/s3/S3Config.java @@ -0,0 +1,70 @@ +package popcong.app.infra.config.s3; + +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.DefaultAWSCredentialsProviderChain; +import com.amazonaws.auth.profile.ProfileCredentialsProvider; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.securitytoken.AWSSecurityTokenService; +import com.amazonaws.services.securitytoken.AWSSecurityTokenServiceClientBuilder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.annotation.Order; + +@Slf4j +@Configuration +public class S3Config { + + @Value("${cloud.aws.region:us-east-2}") + private String region; + + @Bean + public AWSCredentialsProvider awsCredentialsProvider( + @Value("${cloud.aws.profile:}") String profile) { + log.info("[S3] Using profile='{}', region='{}'", profile, region); + return (profile != null && !profile.isBlank()) + ? new ProfileCredentialsProvider(profile) + : DefaultAWSCredentialsProviderChain.getInstance(); + } + + @Bean + @Primary + public AmazonS3 amazonS3(AWSCredentialsProvider creds) { + log.info("[S3] Creds provider = {}", creds.getClass().getSimpleName()); + return AmazonS3ClientBuilder.standard() + .withRegion(Regions.fromName(region)) + .withCredentials(creds) + .withForceGlobalBucketAccessEnabled(true) + .build(); + } + + @Bean + public AWSSecurityTokenService sts(AWSCredentialsProvider creds) { + return AWSSecurityTokenServiceClientBuilder.standard() + .withRegion(Regions.fromName(region)) + .withCredentials(creds) + .build(); + } + + // 앱 기동 후 한 번만 실행되며, 현재 STS 호출 주체를 로깅 + @Bean + @Order(0) + public ApplicationRunner logCaller(AWSSecurityTokenService sts) { + return args -> { + try { + var me = sts.getCallerIdentity( + new com.amazonaws.services.securitytoken.model.GetCallerIdentityRequest()); + log.info("AWS caller identity: account={}, arn={}, userId={}", + me.getAccount(), me.getArn(), me.getUserId()); + } catch (Exception e) { + log.error("Failed to resolve AWS caller identity.", e); + } + }; + + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/infra/config/security/SecurityConfig.java b/src/main/java/popcong/app/infra/config/security/SecurityConfig.java new file mode 100644 index 0000000..a47ff60 --- /dev/null +++ b/src/main/java/popcong/app/infra/config/security/SecurityConfig.java @@ -0,0 +1,74 @@ +package popcong.app.infra.config.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import popcong.app.adapter.in.jwt.CustomOAuth2UserService; +import popcong.app.adapter.in.jwt.JwtFilter; +import popcong.app.adapter.in.jwt.handler.AuthenticationFailureHandler; +import popcong.app.adapter.in.jwt.handler.AuthorizationFailureHandler; +import popcong.app.adapter.in.jwt.handler.OAuth2SuccessHandler; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final SecurityProperties securityProperties; + + private final JwtFilter jwtFilter; + + private final AuthenticationFailureHandler authenticationFailureHandler; + private final AuthorizationFailureHandler authorizationFailureHandler; + + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + + private final SignInRedirectCaptureFilter signInRedirectCaptureFilter; + + /** + * SpringSecurity 보안 규칙 설정 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + // 세션 상태 필요 (oauth2) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + ) + // redirect 파라미터 쿠키 저장 + .addFilterBefore(signInRedirectCaptureFilter, UsernamePasswordAuthenticationFilter.class) + // 필터 체인에 커스텀 필터 추가 + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + // 요청 별 접근 권한 설정 + .authorizeHttpRequests(authorizeRequests -> + authorizeRequests + .requestMatchers(securityProperties.getPermitAll().toArray(new String[0])) + .permitAll() + .anyRequest() + .authenticated() + ) + // security exception 핸들러 + .exceptionHandling(exceptions -> + exceptions.authenticationEntryPoint(authenticationFailureHandler) + .accessDeniedHandler(authorizationFailureHandler) + ) + .oauth2Login(oauth2 -> + oauth2.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + ); + ; + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/popcong/app/infra/config/security/SecurityProperties.java b/src/main/java/popcong/app/infra/config/security/SecurityProperties.java new file mode 100644 index 0000000..b725cd0 --- /dev/null +++ b/src/main/java/popcong/app/infra/config/security/SecurityProperties.java @@ -0,0 +1,21 @@ +package popcong.app.infra.config.security; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "security.path") +public class SecurityProperties { + + /** + * Spring Security 허용 path 관리 + */ + private List permitAll = new ArrayList<>(); +} diff --git a/src/main/java/popcong/app/infra/config/security/SignInRedirectCaptureFilter.java b/src/main/java/popcong/app/infra/config/security/SignInRedirectCaptureFilter.java new file mode 100644 index 0000000..6e6ff2d --- /dev/null +++ b/src/main/java/popcong/app/infra/config/security/SignInRedirectCaptureFilter.java @@ -0,0 +1,53 @@ +package popcong.app.infra.config.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.Duration; +import java.util.Set; + +/** + * redirect 파라미터를 cookie로 보관 + */ +@Slf4j +@Component +public class SignInRedirectCaptureFilter extends OncePerRequestFilter { + + private static final Set ALLOW_LIST = Set.of( + "http://localhost:3000", + "http://popcong.com" // 배포 도메인 (수정) + ); + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith("/oauth2/authorization"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain + ) throws ServletException, IOException { + String redirect = request.getParameter("redirect"); + if (redirect == null && ALLOW_LIST.stream().anyMatch(redirect::startsWith)) { + ResponseCookie cookie = ResponseCookie.from("login_redirect", redirect) + .httpOnly(true) + .secure(false) + .sameSite("Lax") + .path("/") + .maxAge(Duration.ofMinutes(5)) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + chain.doFilter(request, response); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..2c09067 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,10 @@ +spring: + profiles: + include: secret + +cloud: + aws: + profile: dev + region: us-east-2 + s3: + bucket: popcongbucket diff --git a/src/test/java/popcong/app/AppApplicationTests.java b/src/test/java/popcong/app/AppApplicationTests.java new file mode 100644 index 0000000..c39cc47 --- /dev/null +++ b/src/test/java/popcong/app/AppApplicationTests.java @@ -0,0 +1,13 @@ +package popcong.app; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AppApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/popcong/app/BaseTest.java b/src/test/java/popcong/app/BaseTest.java new file mode 100644 index 0000000..1a9e8af --- /dev/null +++ b/src/test/java/popcong/app/BaseTest.java @@ -0,0 +1,11 @@ +package popcong.app; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@ActiveProfiles({"local", "secret"}) +public abstract class BaseTest { +} \ No newline at end of file diff --git a/src/test/java/popcong/app/auth/AuthTest.java b/src/test/java/popcong/app/auth/AuthTest.java new file mode 100644 index 0000000..047af1c --- /dev/null +++ b/src/test/java/popcong/app/auth/AuthTest.java @@ -0,0 +1,64 @@ +package popcong.app.auth; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import popcong.app.adapter.out.jwt.JwtUtils; +import popcong.app.application.user.port.out.SaveUserPort; +import popcong.app.domain.auth.model.AuthInfo; +import popcong.app.domain.user.model.Provider; +import popcong.app.domain.user.model.Role; +import popcong.app.domain.user.model.User; +import popcong.app.domain.user.model.UserRole; + +import java.time.LocalDateTime; +import java.util.UUID; + +@SpringBootTest +@ActiveProfiles("local") +public class AuthTest { + + private static final Logger log = LoggerFactory.getLogger(AuthTest.class); + + @Autowired + private SaveUserPort saveUserPort; + + @Autowired + private JwtUtils jwtUtils; + + @Test + void generateUserAndTokens() { + String postfix = UUID.randomUUID().toString().substring(0, 8); + User user = new User( + null, + Provider.kakao, + postfix, + postfix + "@gmail.com", + "김팝콩", + null, + null, + Role.ROLE_USER, + UserRole.GUEST, + LocalDateTime.now(), + null + ); + + User saved = saveUserPort.saveUser(user); + log.info("테스트 사용자 생성: id={}, email={}", saved.userId(), saved.email()); + + AuthInfo authInfo = AuthInfo.from(saved); + String accessToken = jwtUtils.generateAccessToken(authInfo); + String refreshToken = jwtUtils.generateRefreshToken(authInfo); + + log.info("🔑 AccessToken : {}", accessToken); + log.info("🔑 RefreshToken : {}", refreshToken); + + boolean valid = jwtUtils.validateToken(accessToken); + log.info("valid test : {}", valid); + assert valid; + } +} diff --git a/src/test/java/popcong/app/database/DatabaseConnectionTest.java b/src/test/java/popcong/app/database/DatabaseConnectionTest.java new file mode 100644 index 0000000..06b8291 --- /dev/null +++ b/src/test/java/popcong/app/database/DatabaseConnectionTest.java @@ -0,0 +1,46 @@ +package popcong.app.database; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import popcong.app.BaseTest; + +public class DatabaseConnectionTest extends BaseTest { + + @Autowired + private EntityManager entityManager; + + /** + * DB 연결 테스트 + */ + @Test + void databaseConnectionTest() { + Object result = entityManager + .createNativeQuery("SELECT 'JPA Connection OK' as message") + .getSingleResult(); + + Assertions.assertNotNull(result); + Assertions.assertEquals("JPA Connection OK", result); + System.out.println("JPA Connection OK: " + result); + } + + /** + * DB 정보 확인 (DB 이름 + 버전) + */ + @Test + void databaseInfoTest() { + Object dbName = entityManager + .createNativeQuery("SELECT DATABASE() as current_db") + .getSingleResult(); + + Object version = entityManager + .createNativeQuery("SELECT VERSION() as db_version") + .getSingleResult(); + + System.out.println("Current Database & version: " + dbName + version); + + Assertions.assertNotNull(dbName); + Assertions.assertNotNull(version); + } +} \ No newline at end of file