diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/ISSUE_TEMPLATE/bug-template.md b/.github/ISSUE_TEMPLATE/bug-template.md new file mode 100644 index 00000000..a9ed940a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-template.md @@ -0,0 +1,19 @@ +--- +name: Bug Template +about: 버그를 이슈에 등록한다. +title: '' +labels: '' +assignees: '' + +--- + +## 🤷 버그 내용 + +## ⚠ 버그 재현 방법 +1. +2. +3. + +## 📸 스크린샷 + +## 👄 참고 사항 diff --git a/.github/ISSUE_TEMPLATE/chore-template.md b/.github/ISSUE_TEMPLATE/chore-template.md new file mode 100644 index 00000000..797530bd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore-template.md @@ -0,0 +1,19 @@ +--- +name: Chore Template +about: 운영기능을 이슈에 등록한다. +title: '' +labels: '' +assignees: '' + +--- + +## 🤷 구현할 기능 + +## 🔨 동작 내용 + +- [ ] To-do 1 +- [ ] To-do 2 +- [ ] To-do 3 + +## 📄 참고 사항 + diff --git a/.github/ISSUE_TEMPLATE/feature-template.md b/.github/ISSUE_TEMPLATE/feature-template.md new file mode 100644 index 00000000..04206505 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-template.md @@ -0,0 +1,20 @@ +--- +name: Feature Template +about: 구현할 기능을 이슈에 등록한다. +title: '' +labels: '' +assignees: '' + +--- + +## 🤷 구현할 기능 + +## 🔨 상세 작업 내용 + +- [ ] To-do 1 +- [ ] To-do 2 +- [ ] To-do 3 + +## 📄 참고 사항 + +## ⏰ 예상 소요 기간 diff --git a/.github/ISSUE_TEMPLATE/fix-template.md b/.github/ISSUE_TEMPLATE/fix-template.md new file mode 100644 index 00000000..13a912f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/fix-template.md @@ -0,0 +1,20 @@ +--- +name: Fix Template +about: 픽스 기능을 이슈에 등록한다. +title: '' +labels: '' +assignees: '' + +--- + +## 🤷 픽스할 기능 + +## 🔨 상세 작업 내용 + +- [ ] To-do 1 +- [ ] To-do 2 +- [ ] To-do 3 + +## 📄 참고 사항 + +## ⏰ 예상 소요 기간 diff --git a/.github/ISSUE_TEMPLATE/refactor-template.md b/.github/ISSUE_TEMPLATE/refactor-template.md new file mode 100644 index 00000000..ded98545 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor-template.md @@ -0,0 +1,20 @@ +--- +name: Refactor Template +about: 리펙토링할 기능을 이슈에 등록한다. +title: '' +labels: '' +assignees: '' + +--- + +## 🤷 리펙토링할 기능 + +## 🔨 상세 작업 내용 + +- [ ] To-do 1 +- [ ] To-do 2 +- [ ] To-do 3 + +## 📄 참고 사항 + +## ⏰ 예상 소요 기간 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..bd6c0dde --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +- [ ] 💯 테스트는 잘 통과했나요? +- [ ] 🏗️ 빌드는 성공했나요? +- [ ] 🧹 불필요한 코드는 제거했나요? +- [ ] 💭 이슈는 등록했나요? +- [ ] 🏷️ 라벨은 등록했나요? + +## 작업 내용 + +## 스크린샷 + +## 주의사항 + +Closes #{이슈 번호} diff --git a/.github/workflows/pr-workflow.yml b/.github/workflows/pr-workflow.yml new file mode 100644 index 00000000..d561e7d0 --- /dev/null +++ b/.github/workflows/pr-workflow.yml @@ -0,0 +1,91 @@ +name: MarineLeisure Pull Request Script -Test & Image build +on: + pull_request: + branches: + - main +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + codetest: + name: 코드 테스트 + runs-on: ubuntu-latest + + steps: + - name: branch checkout + uses: actions/checkout@v4 + + - name: JDK setting + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: set Permission + run: chmod +x ./gradlew + + - name: do test + run: ./gradlew test --stacktrace --no-daemon -Dspring.profiles.active=test --info + env: + JAVA_TOOL_OPTIONS: "-Dlogging.level.root=DEBUG" + + tagging: + name: 태깅 및 릴리즈 + runs-on: ubuntu-latest + outputs: + tag_name: ${{ steps.tag_version.outputs.new_tag }} + + steps: + - uses: actions/checkout@v4 + + - name: versioning and tagging + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: releasing + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} + build-image: + name: 도커 이미지 빌드 + runs-on: ubuntu-latest + needs: [ codetest,tagging ] + + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + + - name: Check out Repository + uses: actions/checkout@v4 + + - name: Sign in github container registry + uses: docker/login-action@v3 + with: + registry: ${{env.REGISTRY}} + username: ${{github.actor}} + password: ${{secrets.GITHUB_TOKEN}} + - name: Extract metadata + uses: docker/metadata-action@v5 + with: + images: ${{env.REGISTRY}}/${{env.IMAGE_NAME}} + tags: + type=sha + type=raw,value=${{needs.tagging.outputs.tag_name}} + type=raw,value=latest + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + push: 'true' + tags: ${{steps.meta.outputs.tags}} + labels: ${{steps.meta.outputs.labels}} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..dad9fc82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Environment Variables ### +.env +.env.* +*.env +env.properties +application-secrets.yml +application-secrets.properties + +### Application Properties ### +WEB5_7_7STARBALL_BE/src/main/resources/application-*.yml +/src/main/resources/application-local.yml +/src/main/resources/application-dev.yml + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..079ae907 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM gradle:jdk21 as builder + +WORKDIR /libs + +COPY gradlew . +COPY gradle gradle +COPY build.gradle . +COPY settings.gradle . + +RUN ./gradlew dependencies --no-daemon || true + +COPY src src + +RUN ./gradlew build --no-daemon -x test + +FROM openjdk:21-slim + +WORKDIR /app + +COPY --from=builder /libs/build/libs/*.jar app.jar + +ENTRYPOINT [ "java", "-Dspring.profiles.active=prod" , "-jar", "app.jar" ] + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..de71bf0a --- /dev/null +++ b/build.gradle @@ -0,0 +1,95 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.3' + id 'io.spring.dependency-management' version '1.1.7' +} + +ext { + springAiVersion = "1.0.0" +} + +group = 'sevenstar' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + // spring boot dependencies + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' //JSON 파싱 + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + +//JSON parsing + implementation 'com.fasterxml.jackson.core:jackson-databind' + // db dependencies + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.h2database:h2' + + // security dependencies + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // swagger dependencies + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' + + // geo dependencies + implementation 'org.hibernate:hibernate-spatial:6.5.2.Final' + implementation 'org.locationtech.jts:jts-core:1.19.0' + + // mock-inline + testImplementation 'org.mockito:mockito-inline:5.2.0' + // embedded-redis + testImplementation ('it.ozimov:embedded-redis:0.7.3') { + exclude group: 'org.slf4j', module: 'slf4j-simple' + } + // html parser + implementation 'org.jsoup:jsoup:1.21.1' + + // pdf parsing + implementation 'org.apache.pdfbox:pdfbox:3.0.5' + + testImplementation "org.testcontainers:mysql:1.19.3" + testImplementation "org.testcontainers:junit-jupiter:1.19.3" + +} + +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..405a0b15 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +name: Marine-Leisure + +services: + + db: + image: mysql:8.0 + networks: + - marine-net + container_name: marine_db + restart: always + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: marine + MYSQL_USER: ${DB_USERNAME} + MYSQL_PASSWORD: ${DB_PASSWORD} + ports: + - "3306:3306" + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}" ] + timeout: 20s + retries: 10 + interval: 10s + start_period: 40s + + app: + image: ghcr.io/your-org/your-repo:latest # 최신 이미지 태그 + networks: + - marine-net + container_name: marine_app + restart: always + depends_on: + db: + condition: service_healthy + ports: + - "8080:8080" + environment: + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + env_file: + - .env + +volumes: + db_data: + +networks: + marine-net: + driver: bridge \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ff23a68d --- /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.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..23d15a93 --- /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 00000000..db3a6ac2 --- /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 00000000..e6cbde4e --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'MarineLeisure' diff --git a/src/main/java/sevenstar/marineleisure/MarineLeisureApplication.java b/src/main/java/sevenstar/marineleisure/MarineLeisureApplication.java new file mode 100644 index 00000000..90d0ec1e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/MarineLeisureApplication.java @@ -0,0 +1,20 @@ +package sevenstar.marineleisure; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import sevenstar.marineleisure.global.api.config.properties.KhoaProperties; +import sevenstar.marineleisure.global.api.config.properties.OpenMeteoProperties; +import sevenstar.marineleisure.global.api.config.properties.OpenMeteoProperties; + +@SpringBootApplication +@EnableConfigurationProperties({KhoaProperties.class, OpenMeteoProperties.class}) +public class MarineLeisureApplication { + + public static void main(String[] args) { + SpringApplication.run(MarineLeisureApplication.class, args); + } + +} diff --git a/src/main/java/sevenstar/marineleisure/activity/controller/ActivityController.java b/src/main/java/sevenstar/marineleisure/activity/controller/ActivityController.java new file mode 100644 index 00000000..fd99ff80 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/controller/ActivityController.java @@ -0,0 +1,60 @@ +package sevenstar.marineleisure.activity.controller; + +import static sevenstar.marineleisure.global.exception.enums.ActivityErrorCode.*; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.activity.dto.reponse.ActivityDetailResponse; +import sevenstar.marineleisure.activity.dto.reponse.ActivitySummaryResponse; +import sevenstar.marineleisure.activity.dto.reponse.ActivityWeatherResponse; +import sevenstar.marineleisure.activity.dto.request.ActivityDetailRequest; +import sevenstar.marineleisure.activity.dto.request.ActivityIndexRequest; +import sevenstar.marineleisure.activity.dto.request.ActivityWeatherRequest; +import sevenstar.marineleisure.activity.service.ActivityService; +import sevenstar.marineleisure.global.domain.BaseResponse; +import sevenstar.marineleisure.global.enums.ActivityCategory; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/activities") +public class ActivityController { + + private final ActivityService activityService; + + @GetMapping("/index") + public ResponseEntity>> getActivityIndex(@RequestBody ActivityIndexRequest activityIndexRequest) { + return BaseResponse.success(activityService.getActivitySummary( + activityIndexRequest.latitude(), + activityIndexRequest.longitude(), + activityIndexRequest.global() + )); + } + + @GetMapping("/{activity}/detail") + public ResponseEntity> getActivityDetail(@PathVariable ActivityCategory activity, @RequestBody ActivityDetailRequest activityDetailRequest) { + try { + return BaseResponse.success(activityService.getActivityDetail(activity, activityDetailRequest.latitude(), activityDetailRequest.longitude())); + } catch (RuntimeException e) { + return BaseResponse.error(WEATHER_NOT_FOUND); + } + } + + @GetMapping("/weather") + public ResponseEntity> getActivityWeather(@RequestBody ActivityWeatherRequest activityWeatherRequest) { + try { + return BaseResponse.success(activityService.getWeatherBySpot(activityWeatherRequest.latitude(), activityWeatherRequest.longitude())); + } + catch (Exception e) { + return BaseResponse.error(INVALID_ACTIVITY); + } + } + +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityDetailResponse.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityDetailResponse.java new file mode 100644 index 00000000..124b29d4 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityDetailResponse.java @@ -0,0 +1,10 @@ +package sevenstar.marineleisure.activity.dto.reponse; + +import sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse.ActivityDetail; + +public record ActivityDetailResponse( + String category, + String location, + ActivityDetail activityDetail +) { +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivitySummaryResponse.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivitySummaryResponse.java new file mode 100644 index 00000000..bd741e59 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivitySummaryResponse.java @@ -0,0 +1,11 @@ +package sevenstar.marineleisure.activity.dto.reponse; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.TotalIndex; + +@Builder +public record ActivitySummaryResponse( + String spotName, + TotalIndex totalIndex +) { +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityWeatherResponse.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityWeatherResponse.java new file mode 100644 index 00000000..8b598ec9 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/ActivityWeatherResponse.java @@ -0,0 +1,9 @@ +package sevenstar.marineleisure.activity.dto.reponse; + +public record ActivityWeatherResponse( + String location, + String windSpeed, + String waveHeight, + String waterTemp +) { +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetail.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetail.java new file mode 100644 index 00000000..eb8256a6 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetail.java @@ -0,0 +1,4 @@ +package sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse; + +public interface ActivityDetail { +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailFishingResponse.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailFishingResponse.java new file mode 100644 index 00000000..5694aac2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailFishingResponse.java @@ -0,0 +1,27 @@ +package sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse; + +import java.time.LocalDate; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.TidePhase; +import sevenstar.marineleisure.global.enums.TotalIndex; + +@Builder +public record ActivityDetailFishingResponse( + LocalDate forecastDate, + String timePeriod, + TidePhase tide, + TotalIndex totalIndex, + Float waveHeightMin, + Float waveHeightMax, + Float seaTempMin, + Float seaTempMax, + Float airTempMin, + Float airTempMax, + Float currentSpeedMin, + Float currentSpeedMax, + Float windSpeedMin, + Float windSpeedMax, + Float uvIndex +) implements ActivityDetail { +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailMudflatResponse.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailMudflatResponse.java new file mode 100644 index 00000000..fc307896 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailMudflatResponse.java @@ -0,0 +1,22 @@ +package sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse; + +import java.time.LocalDate; +import java.time.LocalTime; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.TotalIndex; + +@Builder +public record ActivityDetailMudflatResponse( + LocalDate forecastDate, + LocalTime startTime, + LocalTime endTime, + Float uvIndex, + Float airTempMin, + Float airTempMax, + Float windSpeedMin, + Float windSpeedMax, + String weather, + TotalIndex totalIndex +) implements ActivityDetail { +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailScubaResponse.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailScubaResponse.java new file mode 100644 index 00000000..d8b5ea0c --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailScubaResponse.java @@ -0,0 +1,25 @@ +package sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse; + +import java.time.LocalDate; +import java.time.LocalTime; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.TidePhase; +import sevenstar.marineleisure.global.enums.TotalIndex; + +@Builder +public record ActivityDetailScubaResponse( + LocalDate forecastDate, + String timePeriod, + LocalTime sunrise, + LocalTime sunset, + TidePhase tide, + TotalIndex totalIndex, + Float waveHeightMin, + Float waveHeightMax, + Float seaTempMin, + Float seaTempMax, + Float currentSpeedMin, + Float currentSpeedMax +) implements ActivityDetail { +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailSurfingResponse.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailSurfingResponse.java new file mode 100644 index 00000000..dcb6f33e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/ActivityDetailSurfingResponse.java @@ -0,0 +1,19 @@ +package sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse; + +import java.time.LocalDate; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.TotalIndex; + +@Builder +public record ActivityDetailSurfingResponse( + LocalDate forecastDate, + String timePeriod, + Float waveHeight, + Float wavePeriod, + Float windSpeed, + Float seaTemp, + TotalIndex totalIndex, + Float uvIndex +) implements ActivityDetail { +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/mapper/ActivityDetailMapper.java b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/mapper/ActivityDetailMapper.java new file mode 100644 index 00000000..2fbb26fc --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/reponse/activitiyDetailResponse/mapper/ActivityDetailMapper.java @@ -0,0 +1,81 @@ +package sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse.mapper; + +import org.springframework.stereotype.Component; + +import sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse.ActivityDetailFishingResponse; +import sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse.ActivityDetailMudflatResponse; +import sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse.ActivityDetailScubaResponse; +import sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse.ActivityDetailSurfingResponse; +import sevenstar.marineleisure.forecast.domain.Fishing; +import sevenstar.marineleisure.forecast.domain.Mudflat; +import sevenstar.marineleisure.forecast.domain.Scuba; +import sevenstar.marineleisure.forecast.domain.Surfing; + +@Component +public class ActivityDetailMapper { + public static ActivityDetailFishingResponse fromFishing(Fishing fishing) { + return ActivityDetailFishingResponse.builder() + .forecastDate(fishing.getForecastDate()) + .timePeriod(fishing.getTimePeriod().name()) + .tide(fishing.getTide()) + .totalIndex(fishing.getTotalIndex()) + .waveHeightMin(fishing.getWaveHeightMin()) + .waveHeightMax(fishing.getWaveHeightMax()) + .seaTempMin(fishing.getSeaTempMin()) + .seaTempMax(fishing.getSeaTempMax()) + .airTempMin(fishing.getAirTempMin()) + .airTempMax(fishing.getAirTempMax()) + .currentSpeedMin(fishing.getCurrentSpeedMin()) + .currentSpeedMax(fishing.getCurrentSpeedMax()) + .windSpeedMin(fishing.getWindSpeedMin()) + .windSpeedMax(fishing.getWindSpeedMax()) + .uvIndex(fishing.getUvIndex()) + .build(); + } + + public static ActivityDetailMudflatResponse fromMudflat(Mudflat mudflat) { + return ActivityDetailMudflatResponse.builder() + .forecastDate(mudflat.getForecastDate()) + .startTime(mudflat.getStartTime()) + .endTime(mudflat.getEndTime()) + .uvIndex(mudflat.getUvIndex()) + .airTempMin(mudflat.getAirTempMin()) + .airTempMax(mudflat.getAirTempMax()) + .windSpeedMin(mudflat.getWindSpeedMin()) + .windSpeedMax(mudflat.getWindSpeedMax()) + .weather(mudflat.getWeather()) + .totalIndex(mudflat.getTotalIndex()) + .build(); + } + + public static ActivityDetailSurfingResponse fromSurfing(Surfing surfing) { + return ActivityDetailSurfingResponse.builder() + .forecastDate(surfing.getForecastDate()) + .timePeriod(surfing.getTimePeriod().name()) + .waveHeight(surfing.getWaveHeight()) + .wavePeriod(surfing.getWavePeriod()) + .windSpeed(surfing.getWindSpeed()) + .seaTemp(surfing.getSeaTemp()) + .totalIndex(surfing.getTotalIndex()) + .uvIndex(surfing.getUvIndex()) + .build(); + } + + public static ActivityDetailScubaResponse fromScuba(Scuba scuba) { + return ActivityDetailScubaResponse.builder() + .forecastDate(scuba.getForecastDate()) + .timePeriod(scuba.getTimePeriod().name()) + .sunrise(scuba.getSunrise()) + .sunset(scuba.getSunset()) + .tide(scuba.getTide()) + .totalIndex(scuba.getTotalIndex()) + .waveHeightMin(scuba.getWaveHeightMin()) + .waveHeightMax(scuba.getWaveHeightMax()) + .seaTempMin(scuba.getSeaTempMin()) + .seaTempMax(scuba.getSeaTempMax()) + .currentSpeedMin(scuba.getCurrentSpeedMin()) + .currentSpeedMax(scuba.getCurrentSpeedMax()) + .build(); + } + +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/request/ActivityDetailRequest.java b/src/main/java/sevenstar/marineleisure/activity/dto/request/ActivityDetailRequest.java new file mode 100644 index 00000000..0cf11413 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/request/ActivityDetailRequest.java @@ -0,0 +1,11 @@ +package sevenstar.marineleisure.activity.dto.request; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record ActivityDetailRequest( + BigDecimal latitude, + BigDecimal longitude, + LocalDate date +) { +} diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/request/ActivityIndexRequest.java b/src/main/java/sevenstar/marineleisure/activity/dto/request/ActivityIndexRequest.java new file mode 100644 index 00000000..2cf9e28f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/request/ActivityIndexRequest.java @@ -0,0 +1,11 @@ +package sevenstar.marineleisure.activity.dto.request; + +import java.math.BigDecimal; + +public record ActivityIndexRequest( + BigDecimal latitude, + BigDecimal longitude, + boolean global +) { + +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/activity/dto/request/ActivityWeatherRequest.java b/src/main/java/sevenstar/marineleisure/activity/dto/request/ActivityWeatherRequest.java new file mode 100644 index 00000000..8b4bf064 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/dto/request/ActivityWeatherRequest.java @@ -0,0 +1,9 @@ +package sevenstar.marineleisure.activity.dto.request; + +import java.math.BigDecimal; + +public record ActivityWeatherRequest( + BigDecimal latitude, + BigDecimal longitude +) { +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/activity/service/ActivityService.java b/src/main/java/sevenstar/marineleisure/activity/service/ActivityService.java new file mode 100644 index 00000000..d5ecaabb --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/activity/service/ActivityService.java @@ -0,0 +1,235 @@ +package sevenstar.marineleisure.activity.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.activity.dto.reponse.ActivityDetailResponse; +import sevenstar.marineleisure.activity.dto.reponse.ActivitySummaryResponse; +import sevenstar.marineleisure.activity.dto.reponse.ActivityWeatherResponse; +import sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse.ActivityDetail; +import sevenstar.marineleisure.activity.dto.reponse.activitiyDetailResponse.mapper.ActivityDetailMapper; +import sevenstar.marineleisure.forecast.domain.Fishing; +import sevenstar.marineleisure.forecast.domain.Mudflat; +import sevenstar.marineleisure.forecast.domain.Scuba; +import sevenstar.marineleisure.forecast.domain.Surfing; +import sevenstar.marineleisure.forecast.repository.FishingRepository; +import sevenstar.marineleisure.forecast.repository.MudflatRepository; +import sevenstar.marineleisure.forecast.repository.ScubaRepository; +import sevenstar.marineleisure.forecast.repository.SurfingRepository; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@Service +@RequiredArgsConstructor +public class ActivityService { + + private final OutdoorSpotRepository outdoorSpotRepository; + + private final FishingRepository fishingRepository; + private final MudflatRepository mudflatRepository; + private final ScubaRepository scubaRepository; + private final SurfingRepository surfingRepository; + + @Transactional(readOnly = true) + public Map getActivitySummary(BigDecimal latitude, BigDecimal longitude, + boolean global) { + if (global) { + return getGlobalActivitySummary(); + } else { + return getLocalActivitySummary(latitude, longitude); + } + } + + private Map getLocalActivitySummary(BigDecimal latitude, BigDecimal longitude) { + Map responses = new HashMap<>(); + + Fishing fishingBySpot = null; + Mudflat mudflatBySpot = null; + Surfing surfingBySpot = null; + Scuba scubaBySpot = null; + + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1); + + List outdoorSpotList = outdoorSpotRepository.findByCoordinates(latitude, longitude, 10); + + while (fishingBySpot == null || mudflatBySpot == null || surfingBySpot == null || scubaBySpot == null) { + + OutdoorSpot currentSpot; + Long currentSpotId; + + try { + currentSpot = outdoorSpotList.removeFirst(); + currentSpotId = currentSpot.getId(); + } catch (Exception e) { + break; + } + + if (fishingBySpot == null) { + Optional fishingResult = fishingRepository.findFirstBySpotIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc( + currentSpotId, startOfDay, endOfDay); + + if (fishingResult.isPresent()) { + fishingBySpot = fishingResult.get(); + responses.put("Fishing", + new ActivitySummaryResponse(currentSpot.getName(), fishingResult.get().getTotalIndex())); + } + } + + if (mudflatBySpot == null) { + Optional mudflatResult = mudflatRepository.findFirstBySpotIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc( + currentSpotId, startOfDay, endOfDay); + + if (mudflatResult.isPresent()) { + mudflatBySpot = mudflatResult.get(); + responses.put("Mudflat", + new ActivitySummaryResponse(currentSpot.getName(), mudflatResult.get().getTotalIndex())); + } + } + + if (surfingBySpot == null) { + Optional surfingResult = surfingRepository.findFirstBySpotIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc( + currentSpotId, startOfDay, endOfDay); + + if (surfingResult.isPresent()) { + surfingBySpot = surfingResult.get(); + responses.put("Surfing", + new ActivitySummaryResponse(currentSpot.getName(), surfingResult.get().getTotalIndex())); + } + } + + if (scubaBySpot == null) { + Optional scubaResult = scubaRepository.findFirstBySpotIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc( + currentSpotId, startOfDay, endOfDay); + + if (scubaResult.isPresent()) { + scubaBySpot = scubaResult.get(); + responses.put("Scuba", + new ActivitySummaryResponse(currentSpot.getName(), scubaResult.get().getTotalIndex())); + } + } + } + + return responses; + } + + private Map getGlobalActivitySummary() { + Map responses = new HashMap<>(); + + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1); + + Optional fishingResult = fishingRepository.findTopByCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByTotalIndexDesc( + startOfDay, endOfDay); + Optional mudflatResult = mudflatRepository.findTopByCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByTotalIndexDesc( + startOfDay, endOfDay); + Optional surfingResult = surfingRepository.findTopByCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByTotalIndexDesc( + startOfDay, endOfDay); + Optional scubaResult = scubaRepository.findTopByCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByTotalIndexDesc( + startOfDay, endOfDay); + + if (fishingResult.isPresent()) { + Fishing fishing = fishingResult.get(); + OutdoorSpot spot = outdoorSpotRepository.findById(fishing.getSpotId()).get(); + responses.put("Fishing", new ActivitySummaryResponse(spot.getName(), fishing.getTotalIndex())); + } + + if (mudflatResult.isPresent()) { + Mudflat mudflat = mudflatResult.get(); + OutdoorSpot spot = outdoorSpotRepository.findById(mudflat.getSpotId()).get(); + responses.put("Mudflat", new ActivitySummaryResponse(spot.getName(), mudflat.getTotalIndex())); + } + + if (scubaResult.isPresent()) { + Scuba scuba = scubaResult.get(); + OutdoorSpot spot = outdoorSpotRepository.findById(scuba.getSpotId()).get(); + responses.put("Scuba", new ActivitySummaryResponse(spot.getName(), scuba.getTotalIndex())); + } + + if (surfingResult.isPresent()) { + Surfing surfing = surfingResult.get(); + OutdoorSpot spot = outdoorSpotRepository.findById(surfing.getSpotId()).get(); + responses.put("Surfing", new ActivitySummaryResponse(spot.getName(), surfing.getTotalIndex())); + } + + return responses; + } + + @Transactional(readOnly = true) + public ActivityDetailResponse getActivityDetail(ActivityCategory activity, BigDecimal latitude, + BigDecimal longitude) { + + OutdoorSpot nearSpot = outdoorSpotRepository.findByCoordinates(latitude, longitude, 1).getFirst(); + + LocalDateTime today = LocalDate.now().plusDays(1).atStartOfDay(); + + ActivityDetail result; + + switch (activity) { + case FISHING -> { + Fishing resultSearch = fishingRepository.findBySpotIdAndCreatedAtBeforeOrderByCreatedAtDesc( + nearSpot.getId(), today).get(); + result = ActivityDetailMapper.fromFishing(resultSearch); + } + case MUDFLAT -> { + Mudflat resultSearch = mudflatRepository.findBySpotIdAndCreatedAtBeforeOrderByCreatedAtDesc( + nearSpot.getId(), today).get(); + result = ActivityDetailMapper.fromMudflat(resultSearch); + } + case SURFING -> { + Surfing resultSearch = surfingRepository.findBySpotIdAndCreatedAtBeforeOrderByCreatedAtDesc( + nearSpot.getId(), today).get(); + result = ActivityDetailMapper.fromSurfing(resultSearch); + } + case SCUBA -> { + Scuba resultSearch = scubaRepository.findBySpotIdAndCreatedAtBeforeOrderByCreatedAtDesc( + nearSpot.getId(), today).get(); + result = ActivityDetailMapper.fromScuba(resultSearch); + } + default -> { + throw new RuntimeException("WRONG_ACTIVITY"); + } + } + + return new ActivityDetailResponse(activity.toString(), nearSpot.getLocation(), result); + } + + @Transactional(readOnly = true) + public ActivityWeatherResponse getWeatherBySpot(BigDecimal latitude, BigDecimal longitude) { + OutdoorSpot nearSpot = outdoorSpotRepository.findByCoordinates(latitude, longitude, 1).getFirst(); + + Fishing fishingSpot = fishingRepository.findBySpotIdOrderByCreatedAt(nearSpot.getId()).get(); + + if (fishingSpot != null) { + return new ActivityWeatherResponse( + nearSpot.getName(), + fishingSpot.getWindSpeedMax().toString(), + fishingSpot.getWaveHeightMax().toString(), + fishingSpot.getSeaTempMax().toString() + ); + } + + Surfing surfingSpot = surfingRepository.findBySpotIdOrderByCreatedAt(nearSpot.getId()).get(); + + if (surfingSpot != null) { + return new ActivityWeatherResponse( + nearSpot.getName(), + surfingSpot.getWindSpeed().toString(), + surfingSpot.getWaveHeight().toString(), + surfingSpot.getSeaTemp().toString() + ); + } else { + throw new RuntimeException("Spot not found"); + } + } +} diff --git a/src/main/java/sevenstar/marineleisure/alert/controller/AlertController.java b/src/main/java/sevenstar/marineleisure/alert/controller/AlertController.java new file mode 100644 index 00000000..c64a545e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/controller/AlertController.java @@ -0,0 +1,43 @@ +package sevenstar.marineleisure.alert.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.alert.dto.response.JellyfishResponseDto; +import sevenstar.marineleisure.alert.dto.vo.JellyfishDetailVO; +import sevenstar.marineleisure.alert.mapper.AlertMapper; +import sevenstar.marineleisure.alert.service.JellyfishService; +import sevenstar.marineleisure.global.domain.BaseResponse; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/alerts") +public class AlertController { + private final JellyfishService jellyfishService; + private final AlertMapper alertMapper; + + /** + * 사용자에게 해파리출현에 관한 정보를 넘겨주기위한 메서드입니다. + * @return 해파리 발생 관련 정보 + */ + @GetMapping("/jellyfish") + public ResponseEntity> getJellyfishList() { + List items = jellyfishService.search(); + JellyfishResponseDto result = alertMapper.toResponseDto(items); + return BaseResponse.success(result); + } + + // 명시적으로 크롤링작업을 호출하기 위함입니다. 프론트에서 사용하지는 않습니다. + // 동작 테스트 완료했습니다. + // OpenAi Token발생하므로 꼭 필요할때만 사용해주세요. + // @GetMapping("/jellyfish/crawl") + // public ResponseEntity triggerCrawl() { + // jellyfishService.updateLatestReport(); + // return ResponseEntity.ok("해파리 리포트 크롤링 완료"); + // } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/alert/domain/JellyfishRegionDensity.java b/src/main/java/sevenstar/marineleisure/alert/domain/JellyfishRegionDensity.java new file mode 100644 index 00000000..1fa5d7f4 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/domain/JellyfishRegionDensity.java @@ -0,0 +1,55 @@ +package sevenstar.marineleisure.alert.domain; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.DensityLevel; + +@Entity +@Table(name = "jellyfish_region_density") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class JellyfishRegionDensity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "region_name", nullable = false, length = 100) + private String regionName; + @JoinColumn(name = "species_id", nullable = false) + private Long species; + + @Column(name = "report_date", nullable = false) + private LocalDate reportDate; + + @Enumerated(EnumType.STRING) + @Column(name = "density_type", nullable = false, length = 10) + private DensityLevel densityType; + + @Builder + public JellyfishRegionDensity( + String regionName, + Long species, + LocalDate reportDate, + DensityLevel densityType + ) { + this.regionName = regionName; + this.species = species; + this.reportDate = reportDate; + this.densityType = densityType; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/alert/domain/JellyfishSpecies.java b/src/main/java/sevenstar/marineleisure/alert/domain/JellyfishSpecies.java new file mode 100644 index 00000000..340e1255 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/domain/JellyfishSpecies.java @@ -0,0 +1,40 @@ +package sevenstar.marineleisure.alert.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.ToxicityLevel; + +@Entity +@Table(name = "jellyfish_species") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class JellyfishSpecies extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 20) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ToxicityLevel toxicity; + + @Builder + public JellyfishSpecies(String name, ToxicityLevel toxicity) { + this.name = name; + this.toxicity = toxicity; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/alert/dto/response/JellyfishResponseDto.java b/src/main/java/sevenstar/marineleisure/alert/dto/response/JellyfishResponseDto.java new file mode 100644 index 00000000..47b26b3f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/dto/response/JellyfishResponseDto.java @@ -0,0 +1,16 @@ +package sevenstar.marineleisure.alert.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import lombok.Builder; +import sevenstar.marineleisure.alert.dto.vo.JellyfishRegionVO; + +/** + * + * @param reportDate : 리포트 일자 + * @param regions : 지역별 해파리 발생리스트 + */ +@Builder +public record JellyfishResponseDto(LocalDate reportDate, List regions) { +} diff --git a/src/main/java/sevenstar/marineleisure/alert/dto/vo/JellyfishDetailVO.java b/src/main/java/sevenstar/marineleisure/alert/dto/vo/JellyfishDetailVO.java new file mode 100644 index 00000000..35c0b1e2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/dto/vo/JellyfishDetailVO.java @@ -0,0 +1,15 @@ +package sevenstar.marineleisure.alert.dto.vo; + +import java.time.LocalDate; + +public interface JellyfishDetailVO { + String getSpecies(); + + String getRegion(); + + String getDensityType(); + + String getToxicity(); + + LocalDate getReportDate(); +} diff --git a/src/main/java/sevenstar/marineleisure/alert/dto/vo/JellyfishRegionVO.java b/src/main/java/sevenstar/marineleisure/alert/dto/vo/JellyfishRegionVO.java new file mode 100644 index 00000000..bf8816ce --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/dto/vo/JellyfishRegionVO.java @@ -0,0 +1,12 @@ +package sevenstar.marineleisure.alert.dto.vo; + +import lombok.Builder; + +/** + * + * @param regionName : 발생지역 + * @param species : 해당 지역 발생 해파리정보 + */ +@Builder +public record JellyfishRegionVO(String regionName, JellyfishSpeciesVO species) { +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/alert/dto/vo/JellyfishSpeciesVO.java b/src/main/java/sevenstar/marineleisure/alert/dto/vo/JellyfishSpeciesVO.java new file mode 100644 index 00000000..28660a12 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/dto/vo/JellyfishSpeciesVO.java @@ -0,0 +1,13 @@ +package sevenstar.marineleisure.alert.dto.vo; + +import lombok.Builder; + +/** + * + * @param name : 해파리 이름 + * @param toxicity : 독성 + * @param density : 밀도 + */ +@Builder +public record JellyfishSpeciesVO(String name, String toxicity, String density) { +} diff --git a/src/main/java/sevenstar/marineleisure/alert/dto/vo/ParsedJellyfishVO.java b/src/main/java/sevenstar/marineleisure/alert/dto/vo/ParsedJellyfishVO.java new file mode 100644 index 00000000..8450caa2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/dto/vo/ParsedJellyfishVO.java @@ -0,0 +1,14 @@ +package sevenstar.marineleisure.alert.dto.vo; + +/** + * AI를 이용해 뽑아온 데이터 입니다. + * @param species : 종이름 + * @param region : 출현지역 + * @param densityType : 출현 밀도 + */ +public record ParsedJellyfishVO( + String species, + String region, + String densityType) { + +} diff --git a/src/main/java/sevenstar/marineleisure/alert/mapper/AlertMapper.java b/src/main/java/sevenstar/marineleisure/alert/mapper/AlertMapper.java new file mode 100644 index 00000000..dea78b3a --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/mapper/AlertMapper.java @@ -0,0 +1,39 @@ +package sevenstar.marineleisure.alert.mapper; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.alert.dto.response.JellyfishResponseDto; +import sevenstar.marineleisure.alert.dto.vo.JellyfishDetailVO; +import sevenstar.marineleisure.alert.dto.vo.JellyfishRegionVO; +import sevenstar.marineleisure.alert.dto.vo.JellyfishSpeciesVO; +import sevenstar.marineleisure.global.enums.DensityLevel; +import sevenstar.marineleisure.global.enums.ToxicityLevel; + +@Component +@RequiredArgsConstructor +public class AlertMapper { + + public JellyfishResponseDto toResponseDto(List detailList) { + if (detailList.isEmpty()) { + return null; + } + LocalDate reportDate = detailList.get(0).getReportDate(); + + List regions = detailList.stream() + .map(detail -> new JellyfishRegionVO( + detail.getRegion(), + new JellyfishSpeciesVO( + detail.getSpecies(), + ToxicityLevel.valueOf(detail.getToxicity()).getDescription(), + DensityLevel.valueOf(detail.getDensityType()).getDescription() + ) + )) + .toList(); + + return new JellyfishResponseDto(reportDate, regions); + } +} diff --git a/src/main/java/sevenstar/marineleisure/alert/repository/JellyfishRegionDensityRepository.java b/src/main/java/sevenstar/marineleisure/alert/repository/JellyfishRegionDensityRepository.java new file mode 100644 index 00000000..40410f1b --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/repository/JellyfishRegionDensityRepository.java @@ -0,0 +1,27 @@ +package sevenstar.marineleisure.alert.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import sevenstar.marineleisure.alert.domain.JellyfishRegionDensity; +import sevenstar.marineleisure.alert.dto.vo.JellyfishDetailVO; + +public interface JellyfishRegionDensityRepository extends JpaRepository { + + @Query(value = """ + SELECT + s.name AS species, + r.region_name AS region, + r.density_type AS densityType, + s.toxicity AS toxicity, + r.report_date AS reportDate + FROM jellyfish_region_density r + JOIN jellyfish_species s ON r.species = s.id + WHERE r.report_date = ( + SELECT MAX(r2.report_date) FROM jellyfish_region_density r2 + ) + """, nativeQuery = true) + List findLatestJellyfishDetails(); +} diff --git a/src/main/java/sevenstar/marineleisure/alert/repository/JellyfishSpeciesRepository.java b/src/main/java/sevenstar/marineleisure/alert/repository/JellyfishSpeciesRepository.java new file mode 100644 index 00000000..54a55e2e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/repository/JellyfishSpeciesRepository.java @@ -0,0 +1,14 @@ +package sevenstar.marineleisure.alert.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import sevenstar.marineleisure.alert.domain.JellyfishSpecies; + +@Repository +public interface JellyfishSpeciesRepository extends JpaRepository { + Optional findByName(String name); + +} diff --git a/src/main/java/sevenstar/marineleisure/alert/service/AlertService.java b/src/main/java/sevenstar/marineleisure/alert/service/AlertService.java new file mode 100644 index 00000000..2164170f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/service/AlertService.java @@ -0,0 +1,14 @@ +package sevenstar.marineleisure.alert.service; + +import java.util.List; + +public interface AlertService { + + /** + * 위험요소 발생 목록 조회 + *[GET] /alerts + * 추가 기능으로 적조도 분석도 구현할 가능성이 있기에, 제네릭으로 두었습니다. + * @return 지역별 워험요소 발생 목록 + */ + public List search(); +} diff --git a/src/main/java/sevenstar/marineleisure/alert/service/JellyfishService.java b/src/main/java/sevenstar/marineleisure/alert/service/JellyfishService.java new file mode 100644 index 00000000..9dbaa9f8 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/service/JellyfishService.java @@ -0,0 +1,143 @@ +package sevenstar.marineleisure.alert.service; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.alert.domain.JellyfishRegionDensity; +import sevenstar.marineleisure.alert.domain.JellyfishSpecies; +import sevenstar.marineleisure.alert.dto.vo.JellyfishDetailVO; +import sevenstar.marineleisure.alert.dto.vo.ParsedJellyfishVO; +import sevenstar.marineleisure.alert.repository.JellyfishRegionDensityRepository; +import sevenstar.marineleisure.alert.repository.JellyfishSpeciesRepository; +import sevenstar.marineleisure.alert.util.JellyfishCrawler; +import sevenstar.marineleisure.alert.util.JellyfishParser; +import sevenstar.marineleisure.global.enums.DensityLevel; +import sevenstar.marineleisure.global.enums.ToxicityLevel; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JellyfishService implements AlertService { + + private final JellyfishRegionDensityRepository densityRepository; + private final JellyfishSpeciesRepository speciesRepository; + private final JellyfishParser parser; + private final JellyfishCrawler crawler; + private final RestTemplate restTemplate = new RestTemplate(); + + /** + * 가장최신의 지역별 해파리 발생 리스트를 반환합니다. + * [GET] /alerts/jellyfish + * @return 지역별해파리 발생리스트 + */ + @Override + public List search() { + return densityRepository.findLatestJellyfishDetails(); + } + + /** + * + * @param name : 이름으로 해파리종의 정보를 찾습니다. + * @return 해당 해파리 JellyfishSpecies객체 + */ + @Transactional(readOnly = true) + public JellyfishSpecies searchByName(String name) { + return speciesRepository.findByName(name).orElse(null); + } + + /** + * 웹에서 크롤링 해 Pdf를 DB에 적재합니다. + */ + // @Scheduled(cron = "0 0 0 ? * FRI") + // 금요일 00시에 동작합니다. + @Transactional + public void updateLatestReport() { + try { + //웹에서 보고서파일 크롤링 + File pdfFile = crawler.downloadLastedPdf(); + + //파일 명에서 보고일자 추출 + LocalDate reportDate = parser.extractDateFromFileName(pdfFile.getName()); + log.info("reportDate : {}", reportDate.toString()); + + //OpenAI를 통해서 보고서 내용 Dto로 반환 + List parsedJellyfishVOS = parser.parsePdfToJson(pdfFile); + + //Dto를 이용하여 기존 해파리 목록 검색후, 해파리 지역별 분포 DB에 적재 + for (ParsedJellyfishVO dto : parsedJellyfishVOS) { + JellyfishSpecies species = searchByName(dto.species()); + + //기존 DB에 없는 신종일경우, 새로 등록 후 data.sql에도 구문 추가 + if (species == null) { + species = JellyfishSpecies.builder() + .name(dto.species()) + .toxicity(ToxicityLevel.NONE) + .build(); + speciesRepository.save(species); + log.info("신종 해파리등록 : {}", dto.species()); + + appendToDataSql(dto.species(), ToxicityLevel.NONE); + } + + DensityLevel densityLevel = dto.densityType().equals("HIGH") ? DensityLevel.HIGH : DensityLevel.LOW; + + //DB에 적재 + JellyfishRegionDensity regionDensity = JellyfishRegionDensity.builder() + .regionName(dto.region()) + .reportDate(reportDate) + .densityType(densityLevel) + .species(species.getId()) + .build(); + + densityRepository.save(regionDensity); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * DB적재중 신종해파리 등록시 자동으로 data.sql에 INSERT문을 추가하는 메서드입니다. + * @param speciesName 신종 해파리 등록 + * @param toxicity 무독성 고정 + */ + private void appendToDataSql(String speciesName, ToxicityLevel toxicity) { + try { + + String resourcePath = "src/main/resources/data.sql"; + Path dataFilePath = Paths.get(resourcePath); + + if (!Files.exists(dataFilePath)) { + Files.createFile(dataFilePath); + log.info("data.sql 파일 생성"); + } + + String insertStatement = String.format( + "INSERT INTO jellyfish_species (name, toxicity, created_at, updated_at)\n" + + "VALUES ('%s', '%s', NOW(), NOW());\n", + speciesName, toxicity.name() + ); + + Files.write(dataFilePath, insertStatement.getBytes(StandardCharsets.UTF_8), + StandardOpenOption.APPEND); + + log.info("새로운 종 인서트문 생성: {}", speciesName); + + } catch (IOException e) { + log.error("쓰기 실패", e); + } + } +} diff --git a/src/main/java/sevenstar/marineleisure/alert/util/JellyfishCrawler.java b/src/main/java/sevenstar/marineleisure/alert/util/JellyfishCrawler.java new file mode 100644 index 00000000..84712a3a --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/util/JellyfishCrawler.java @@ -0,0 +1,75 @@ +package sevenstar.marineleisure.alert.util; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class JellyfishCrawler { + private final RestTemplate template = new RestTemplate(); + private final String siteUrl = "https://www.nifs.go.kr"; + private final String boardUrl = siteUrl + "/board/actionBoard0022List.do"; + + /** + * @return 최신게시글의 첨부파일 pdf객체 + * @throws IOException + */ + public File downloadLastedPdf() throws IOException { + + Document doc = Jsoup.connect(boardUrl).get(); + + // 첫 게시글 연결 + Element firstRow = doc.select("div.board-list table tbody tr").first(); + if (firstRow == null) { + log.warn("게시글 행을 찾을수 없습니다."); + return null; + } + + // 첫 게시글의 첨부파일 + Element fileLink = firstRow.selectFirst("td[data-label = 원본] a"); + if (fileLink == null) { + log.warn("첨부파일 링크를 찾을수 없습니다."); + return null; + } + String fileUrl = siteUrl + fileLink.attr("href"); + log.info("최신 해파리 리포트 pdf 링크 : {}", fileUrl); + + // 첫 게시글의 업로드 일자 추출 + Element dateElement = firstRow.selectFirst("td[data-label = 작성일]"); + LocalDate uploadDate = null; + if (dateElement != null) { + try { + uploadDate = LocalDate.parse(dateElement.text().trim(), DateTimeFormatter.ofPattern("yyyy-MM-dd")); + } catch (Exception e) { + log.warn("업로드 날짜 파싱 실패: {}", dateElement.text()); + } + if (uploadDate == null) { + uploadDate = LocalDate.now(); // fallback + } + } + String formattedDate = uploadDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String fileName = "jellyfish_" + formattedDate + ".pdf"; + + ResponseEntity response = template.getForEntity(fileUrl, byte[].class); + byte[] pdfBytes = response.getBody(); + + File savedFile = new File(System.getProperty("java.io.tmpdir"), fileName); + Files.write(savedFile.toPath(), pdfBytes); + log.info("PDF 파일 다운로드 완료: {}", savedFile.getAbsolutePath()); + + return savedFile; + } + +} diff --git a/src/main/java/sevenstar/marineleisure/alert/util/JellyfishExtractor.java b/src/main/java/sevenstar/marineleisure/alert/util/JellyfishExtractor.java new file mode 100644 index 00000000..2b71f5b8 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/util/JellyfishExtractor.java @@ -0,0 +1,74 @@ +package sevenstar.marineleisure.alert.util; + +import java.util.List; + +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.alert.dto.vo.ParsedJellyfishVO; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JellyfishExtractor { + + private final OpenAiChatModel chatModel; + private final ObjectMapper objectMapper; + + public List extractJellyfishData(String text) { + try { + String instruction = """ + 다음은 해파리 주간 보고서의 일부입니다. + + 대량출현해파리 및 독성해파리 항목을 보고, 종 이름, 출현 지역, 밀도를 다음 JSON 배열 형식으로 반환해주세요. + + 형식: + [ + { + "species": "보름달물해파리", + "region": "부산", + "densityType": "HIGH" + } + ] + + 규칙: + - 한 종이 여러 지역에 나타나면, 지역마다 별도 객체로 나눠주세요. + - densityType은 고밀도 → HIGH, 저밀도 → LOW + 텍스트: + """ + text; + + Prompt prompt = new Prompt(instruction); + String jsonResponse = chatModel.call(prompt).getResult().getOutput().getText(); + + log.info("AI Response: {}", jsonResponse); + + //AI응답 시작점 끝점지정(JSON만 파싱) + int start = jsonResponse.indexOf('['); + int end = jsonResponse.lastIndexOf(']'); + + if (start == -1 || end == -1) { + log.error("JSON 배열이 응답에서 발견되지 않았습니다."); + return List.of(); + } + + String jsonArrayOnly = jsonResponse.substring(start, end + 1); + + return objectMapper.readValue( + jsonArrayOnly, + new TypeReference>() { + } + ); + + } catch (Exception e) { + log.error("pdf에서 AI를 통해 JSON으로 파싱하는 도중 에러가 발생하였습니다.", e); + + return List.of(); + } + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/alert/util/JellyfishParser.java b/src/main/java/sevenstar/marineleisure/alert/util/JellyfishParser.java new file mode 100644 index 00000000..2f88ff29 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/alert/util/JellyfishParser.java @@ -0,0 +1,74 @@ +package sevenstar.marineleisure.alert.util; + +import java.io.File; +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.alert.dto.vo.ParsedJellyfishVO; + +/** + * 해파리 주간보고pdf를 파싱하여 DB에 적재할수 있도록 ParsedJellusifhData를 만들어 주는 파서입니다. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class JellyfishParser { + + private final JellyfishExtractor extractor; + private final ObjectMapper objectMapper; + + public List parsePdfToJson(File pdfFile) { + // 파일에서 ai호출할 부분 추출 + String rawString = extractSummarySection(pdfFile); + + //추출한 텍스트에서 json 형태로 데이터 정형화후 List형태로 반환 + return extractor.extractJellyfishData(rawString); + } + + public String extractSummarySection(File pdfFile) { + try (PDDocument document = Loader.loadPDF(pdfFile)) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(1); + stripper.setEndPage(1); + + String text = stripper.getText(document); + + int start = text.indexOf("◇ 대량출현해파리"); + int end = text.indexOf("■ 해파리 주간 동향"); + + if (start != -1 && end != -1 && start < end) { + return text.substring(start, end).trim(); + } + + return text; + } catch (IOException e) { + throw new RuntimeException("PDF 읽기 실패", e); + } + } + + public LocalDate extractDateFromFileName(String name) { + Pattern pattern = Pattern.compile("(\\d{8})"); + Matcher matcher = pattern.matcher(name); + + if (matcher.find()) { + String dateStr = matcher.group(1); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd"); + return LocalDate.parse(dateStr, formatter); + } else { + throw new IllegalArgumentException("파일 이름에 날짜가 없습니다: " + name); + } + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/favorite/controller/FavoriteController.java b/src/main/java/sevenstar/marineleisure/favorite/controller/FavoriteController.java new file mode 100644 index 00000000..58ffc236 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/favorite/controller/FavoriteController.java @@ -0,0 +1,93 @@ +package sevenstar.marineleisure.favorite.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.favorite.domain.FavoriteSpot; +import sevenstar.marineleisure.favorite.dto.response.FavoriteGetListDto; +import sevenstar.marineleisure.favorite.dto.response.FavoritePatchDto; +import sevenstar.marineleisure.favorite.dto.vo.FavoriteItemVO; +import sevenstar.marineleisure.favorite.mapper.FavoriteMapper; +import sevenstar.marineleisure.favorite.service.FavoriteServiceImpl; +import sevenstar.marineleisure.global.domain.BaseResponse; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.FavoriteErrorCode; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/favorite") +public class FavoriteController { + + private final FavoriteServiceImpl service; + private final FavoriteMapper mapper; + + /** + * 스팟id로 로그인 유저의 즐겨찾기 목록에 추가 + * @param id : 즐겨찾기에 추가할 spotId + * @return 즐겨찾기 추가된 스팟 id + */ + @PostMapping("/{id}") + public ResponseEntity> addFavorite(@PathVariable Long id) { + service.createFavorite(id); + return BaseResponse.success(id); + } + + /** + * 현재 로그인 유저의 즐겨찾기 목록 반환 + * @return 즐겨찾기 목록 + */ + @GetMapping + public ResponseEntity> searchFavorites( + @RequestParam(defaultValue = "0") Long cursorId, + @RequestParam(defaultValue = "20") @Min(1) @Max(10) int size) { + List result = service.searchFavorite(cursorId, size); + + boolean hasNext = result.size() > size; + List items = hasNext ? result.subList(0, size) : result; + + return BaseResponse.success(new FavoriteGetListDto(items, cursorId, size, hasNext)); + } + + /** + * 즐겨찾기 id로 삭제 + * @param id : 즐겨찾기 id + * @return body가 없음 + */ + @DeleteMapping("/{id}") + public ResponseEntity> removeFavorites(@PathVariable Long id) { + // 즐겨찾기 id 형식 검사 + if (id == null || id <= 0) { + throw new CustomException(FavoriteErrorCode.INVALID_FAVORITE_PARAMETER); + } + service.removeFavorite(id); + return ResponseEntity.noContent().build(); + } + + /** + * 즐겨찾기 id로 해당 스팟에 대한 알림 기능 활성화 비활성화 + * @param id : 즐겨찾기 id + * @return 즐겨찾기 id,현재 알림 상태 + */ + @PatchMapping("/{id}") + public ResponseEntity> updateFavorites(@PathVariable Long id) { + // 즐겨찾기 id 형식 검사 + if (id == null || id <= 0) { + throw new CustomException(FavoriteErrorCode.INVALID_FAVORITE_PARAMETER); + } + FavoriteSpot updatedSpot = service.updateNotification(id); + return BaseResponse.success(mapper.toPatchDto(updatedSpot)); + } + +} diff --git a/src/main/java/sevenstar/marineleisure/favorite/domain/FavoriteSpot.java b/src/main/java/sevenstar/marineleisure/favorite/domain/FavoriteSpot.java new file mode 100644 index 00000000..5c0a4be2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/favorite/domain/FavoriteSpot.java @@ -0,0 +1,44 @@ +package sevenstar.marineleisure.favorite.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; + +@Entity +@Table(name = "favorite_spots") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FavoriteSpot extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "spot_id", nullable = false) + private Long spotId; + + @Column(nullable = false) + private Boolean notification = true; + + @Builder + public FavoriteSpot(Long memberId, Long spotId) { + this.memberId = memberId; + this.spotId = spotId; + } + + public void toggleNotification() { + this.notification = !this.notification; + } + +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/favorite/dto/response/FavoriteGetListDto.java b/src/main/java/sevenstar/marineleisure/favorite/dto/response/FavoriteGetListDto.java new file mode 100644 index 00000000..dcec119c --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/favorite/dto/response/FavoriteGetListDto.java @@ -0,0 +1,17 @@ +package sevenstar.marineleisure.favorite.dto.response; + +import java.util.List; + +import lombok.Builder; +import sevenstar.marineleisure.favorite.dto.vo.FavoriteItemVO; + +/** + * + * @param favorites : 즐겨찾기장소 리스트 + * @param cursorId : 커서위치 + * @param size : 한번에 보여줄 아이템개수 + * @param hasNext : 다음 내용 존재여부 + */ +@Builder +public record FavoriteGetListDto(List favorites, Long cursorId, int size, boolean hasNext) { +} diff --git a/src/main/java/sevenstar/marineleisure/favorite/dto/response/FavoritePatchDto.java b/src/main/java/sevenstar/marineleisure/favorite/dto/response/FavoritePatchDto.java new file mode 100644 index 00000000..aae4e1e7 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/favorite/dto/response/FavoritePatchDto.java @@ -0,0 +1,12 @@ +package sevenstar.marineleisure.favorite.dto.response; + +import lombok.Builder; + +/** + * + * @param favoriteId : 즐겨찾기 id + * @param notification : 현재 알림 상황 + */ +@Builder +public record FavoritePatchDto(Long favoriteId, boolean notification) { +} diff --git a/src/main/java/sevenstar/marineleisure/favorite/dto/vo/FavoriteItemVO.java b/src/main/java/sevenstar/marineleisure/favorite/dto/vo/FavoriteItemVO.java new file mode 100644 index 00000000..0372a84d --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/favorite/dto/vo/FavoriteItemVO.java @@ -0,0 +1,16 @@ +package sevenstar.marineleisure.favorite.dto.vo; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.ActivityCategory; + +/** + * + * @param id : 즐겨찾기 id + * @param name : 장소이름 + * @param category : 장소의 활동 목적 구분 + * @param location : 위치 + * @param notification : 알림 여부 + */ +@Builder +public record FavoriteItemVO(Long id, String name, ActivityCategory category, String location, boolean notification) { +} diff --git a/src/main/java/sevenstar/marineleisure/favorite/mapper/FavoriteMapper.java b/src/main/java/sevenstar/marineleisure/favorite/mapper/FavoriteMapper.java new file mode 100644 index 00000000..435a8f4b --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/favorite/mapper/FavoriteMapper.java @@ -0,0 +1,20 @@ +package sevenstar.marineleisure.favorite.mapper; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.favorite.domain.FavoriteSpot; +import sevenstar.marineleisure.favorite.dto.response.FavoritePatchDto; + +@Component +@RequiredArgsConstructor +public class FavoriteMapper { + + public FavoritePatchDto toPatchDto(FavoriteSpot fav) { + return FavoritePatchDto.builder() + .favoriteId(fav.getId()) + .notification(fav.getNotification()) + .build(); + } + +} diff --git a/src/main/java/sevenstar/marineleisure/favorite/repository/FavoriteRepository.java b/src/main/java/sevenstar/marineleisure/favorite/repository/FavoriteRepository.java new file mode 100644 index 00000000..bfa00318 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/favorite/repository/FavoriteRepository.java @@ -0,0 +1,38 @@ +package sevenstar.marineleisure.favorite.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import sevenstar.marineleisure.favorite.domain.FavoriteSpot; +import sevenstar.marineleisure.favorite.dto.vo.FavoriteItemVO; + +@Repository +public interface FavoriteRepository extends JpaRepository { + void deleteFavoriteSpotById(Long id); + + @Query(""" + SELECT new sevenstar.marineleisure.favorite.dto.vo.FavoriteItemVO( + fs.id, + os.name, + os.category, + os.location, + fs.notification + ) + FROM FavoriteSpot fs + JOIN OutdoorSpot os ON fs.spotId = os.id + WHERE fs.memberId = :memberId + AND (:cursorId IS NULL OR fs.id > :cursorId) + ORDER BY fs.id ASC + """) + List findFavoritesByMemberIdAndCursorId( + @Param("memberId") Long memberId, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + boolean existsByMemberIdAndSpotId(Long memberId, Long spotId); +} diff --git a/src/main/java/sevenstar/marineleisure/favorite/service/FavoriteService.java b/src/main/java/sevenstar/marineleisure/favorite/service/FavoriteService.java new file mode 100644 index 00000000..46d6c9f0 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/favorite/service/FavoriteService.java @@ -0,0 +1,40 @@ +package sevenstar.marineleisure.favorite.service; + +import java.util.List; + +import sevenstar.marineleisure.favorite.domain.FavoriteSpot; +import sevenstar.marineleisure.favorite.dto.vo.FavoriteItemVO; + +public interface FavoriteService { + + /** + * [POST] /favorites + * @param id : 스팟 id + * @return 즐겨찾기 추가된 스팟 id + * 즐겨찾기 추가후 해당 스팟 id 반환 + */ + public Long createFavorite(Long id); + + /** + * [GET] /favorites + * @param cursorId : 커서 위치 + * @param size : 한번에 보여줄 아이템 크기 + * @return 즐겨찾기 목록 + */ + public List searchFavorite(Long cursorId, int size); + + /** + * [DELETE] /favorites/{id} + * @param id : 즐겨찾기 id + * 즐겨찾기 목록에서 삭제 + */ + public void removeFavorite(Long id); + + /** + * [UPDATE] /favorites/{id} + * @param id : 즐겨찾기 id + * @return 해당 즐겨찾기 엔티티 반환 + * 즐겨찾기의 알림설정 전환 + */ + public FavoriteSpot updateNotification(Long id); +} diff --git a/src/main/java/sevenstar/marineleisure/favorite/service/FavoriteServiceImpl.java b/src/main/java/sevenstar/marineleisure/favorite/service/FavoriteServiceImpl.java new file mode 100644 index 00000000..7c81086e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/favorite/service/FavoriteServiceImpl.java @@ -0,0 +1,116 @@ +package sevenstar.marineleisure.favorite.service; + +import static sevenstar.marineleisure.global.util.CurrentUserUtil.*; + +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.favorite.domain.FavoriteSpot; +import sevenstar.marineleisure.favorite.dto.vo.FavoriteItemVO; +import sevenstar.marineleisure.favorite.repository.FavoriteRepository; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.FavoriteErrorCode; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FavoriteServiceImpl implements FavoriteService { + private final FavoriteRepository favoriteRepository; + private final OutdoorSpotRepository spotRepository; + + /** + * id로 즐겨찾기 추출 및 유효성 검사 + * @param id : 즐겨찾기 id + * @return 즐겨찾기 객체 + */ + public FavoriteSpot searchFavoriteById(Long id) { + return favoriteRepository.findById(id) + .orElseThrow(() -> new CustomException(FavoriteErrorCode.FAVORITE_NOT_FOUND)); + } + + /** + * 스팟id로 즐겨찾기 추가입니다. + * @param id : 스팟 id + * @return 즐겨찾기한 스팟 id + */ + @Override + @Transactional + public Long createFavorite(Long id) { + Long currentMemberId = getCurrentUserId(); + // 우선 즐겨찾기를 못찾았다고 넣었지만, 나중에 Spot에러코드 추가되면 그걸로 교체 예정입니다. + OutdoorSpot outdoorSpot = spotRepository.findById(id) + .orElseThrow(() -> new CustomException(FavoriteErrorCode.FAVORITE_NOT_FOUND)); + + FavoriteSpot createdFavoriteSpot = FavoriteSpot.builder() + .memberId(currentMemberId) + .spotId(outdoorSpot.getId()) + .build(); + + favoriteRepository.save(createdFavoriteSpot); + return id; + } + + /** + * 즐겨찾기 목록을 반환합니다. + * @param cursorId : 커서 위치 + * @param size : 한번에 보여줄 아이템 크기 + * @return : 사용자에게 보여줄 즐겨찾기 내용객체 리스트 + */ + @Override + @Transactional(readOnly = true) + public List searchFavorite(Long cursorId, int size) { + Long currentMemberId = getCurrentUserId(); + + Pageable pageable = PageRequest.of(0, size + 1); + List result = favoriteRepository.findFavoritesByMemberIdAndCursorId(currentMemberId, + cursorId, pageable); + + return result; + } + + /** + * 즐겨찾기 목록에서 삭제 + * @param id : 즐겨찾기 id + */ + @Override + @Transactional + public void removeFavorite(Long id) { + FavoriteSpot favoriteSpot = searchFavoriteById(id); + + //유저 권한 검사 + Long currentMemberId = getCurrentUserId(); + if (!favoriteSpot.getMemberId().equals(currentMemberId)) { + throw new CustomException(FavoriteErrorCode.FORBIDDEN_FAVORITE_ACCESS); + } + + favoriteRepository.deleteFavoriteSpotById(id); + } + + /** + * 즐겨찾기 업데이트 + * @param id : 즐겨찾기 id + * @return 업데이트한 즐겨찾기 객체 + */ + @Override + @Transactional + public FavoriteSpot updateNotification(Long id) { + FavoriteSpot favoriteSpot = searchFavoriteById(id); + + //유저 권한 검사 + Long currentMemberId = getCurrentUserId(); + if (!favoriteSpot.getMemberId().equals(currentMemberId)) { + throw new CustomException(FavoriteErrorCode.FORBIDDEN_FAVORITE_ACCESS); + } + + favoriteSpot.toggleNotification(); + return favoriteSpot; + } +} diff --git a/src/main/java/sevenstar/marineleisure/forecast/domain/Fishing.java b/src/main/java/sevenstar/marineleisure/forecast/domain/Fishing.java new file mode 100644 index 00000000..7e50b1b9 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/domain/Fishing.java @@ -0,0 +1,114 @@ +package sevenstar.marineleisure.forecast.domain; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.TidePhase; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "fishing_forecast", uniqueConstraints = @UniqueConstraint(columnNames = {"spot_id", "forecast_date", + "time_period"})) +public class Fishing extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "spot_id", nullable = false) + private Long spotId; + + @Column(name = "target_id") + private Long targetId; + + @Column(name = "forecast_date", nullable = false) + private LocalDate forecastDate; + + @Column(name = "time_period", length = 10) + @Enumerated(EnumType.STRING) + private TimePeriod timePeriod; + + @Column(name = "tide") + @Enumerated(EnumType.STRING) + private TidePhase tide; + + @Column(name = "total_index", nullable = false) + @Enumerated(EnumType.STRING) + private TotalIndex totalIndex; + + @Column(name = "wave_height_min") + private Float waveHeightMin; + + @Column(name = "wave_height_max") + private Float waveHeightMax; + + @Column(name = "sea_temp_min") + private Float seaTempMin; + + @Column(name = "sea_temp_max") + private Float seaTempMax; + + @Column(name = "air_temp_min") + private Float airTempMin; + + @Column(name = "air_temp_max") + private Float airTempMax; + + @Column(name = "current_speed_min") + private Float currentSpeedMin; + + @Column(name = "current_speed_max") + private Float currentSpeedMax; + + @Column(name = "wind_speed_min") + private Float windSpeedMin; + + @Column(name = "wind_speed_max") + private Float windSpeedMax; + + @Column(name = "uv_index") + private Float uvIndex; + + @Builder(toBuilder = true) + public Fishing(Long spotId, Long targetId, LocalDate forecastDate, TimePeriod timePeriod, TidePhase tide, + TotalIndex totalIndex, Float waveHeightMin, Float waveHeightMax, Float seaTempMin, Float seaTempMax, + Float airTempMin, Float airTempMax, Float currentSpeedMin, Float currentSpeedMax, Float windSpeedMin, + Float windSpeedMax, Float uvIndex) { + this.spotId = spotId; + this.targetId = targetId; + this.forecastDate = forecastDate; + this.timePeriod = timePeriod; + this.tide = tide; + this.totalIndex = totalIndex; + this.waveHeightMin = waveHeightMin; + this.waveHeightMax = waveHeightMax; + this.seaTempMin = seaTempMin; + this.seaTempMax = seaTempMax; + this.airTempMin = airTempMin; + this.airTempMax = airTempMax; + this.currentSpeedMin = currentSpeedMin; + this.currentSpeedMax = currentSpeedMax; + this.windSpeedMin = windSpeedMin; + this.windSpeedMax = windSpeedMax; + this.uvIndex = uvIndex; + } + + public void updateUvIndex(Float uvIndex) { + this.uvIndex = uvIndex; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/forecast/domain/FishingTarget.java b/src/main/java/sevenstar/marineleisure/forecast/domain/FishingTarget.java new file mode 100644 index 00000000..e1c7e2f8 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/domain/FishingTarget.java @@ -0,0 +1,28 @@ +package sevenstar.marineleisure.forecast.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "fishing_targets") +public class FishingTarget { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 50, unique = true) + private String name; + + public FishingTarget(String name) { + this.name = name; + } +} diff --git a/src/main/java/sevenstar/marineleisure/forecast/domain/Mudflat.java b/src/main/java/sevenstar/marineleisure/forecast/domain/Mudflat.java new file mode 100644 index 00000000..6ac01d72 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/domain/Mudflat.java @@ -0,0 +1,85 @@ +package sevenstar.marineleisure.forecast.domain; + +import java.time.LocalDate; +import java.time.LocalTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.TotalIndex; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "mudflat_forecast", uniqueConstraints = {@UniqueConstraint(columnNames = {"spot_id", "forecast_date"})}) +public class Mudflat extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "spot_id", nullable = false) + private Long spotId; + + @Column(name = "forecast_date", nullable = false) + private LocalDate forecastDate; + + @Column(name = "start_time") + private LocalTime startTime; + + @Column(name = "end_time") + private LocalTime endTime; + + @Column(name = "uv_index") + private Float uvIndex; + + @Column(name = "air_temp_min") + private Float airTempMin; + + @Column(name = "air_temp_max") + private Float airTempMax; + + @Column(name = "wind_speed_min") + private Float windSpeedMin; + + @Column(name = "wind_speed_max") + private Float windSpeedMax; + + @Column(name = "weather") + private String weather; + + @Column(name = "total_index") + @Enumerated(EnumType.STRING) + private TotalIndex totalIndex; + + @Builder + public Mudflat(Long spotId, LocalDate forecastDate, LocalTime startTime, LocalTime endTime, Float uvIndex, + Float airTempMin, Float airTempMax, Float windSpeedMin, Float windSpeedMax, String weather, + TotalIndex totalIndex) { + this.spotId = spotId; + this.forecastDate = forecastDate; + this.startTime = startTime; + this.endTime = endTime; + this.uvIndex = uvIndex; + this.airTempMin = airTempMin; + this.airTempMax = airTempMax; + this.windSpeedMin = windSpeedMin; + this.windSpeedMax = windSpeedMax; + this.weather = weather; + this.totalIndex = totalIndex; + } + + public void updateUvIndex(Float uvIndex) { + this.uvIndex = uvIndex; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/forecast/domain/Scuba.java b/src/main/java/sevenstar/marineleisure/forecast/domain/Scuba.java new file mode 100644 index 00000000..930648cf --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/domain/Scuba.java @@ -0,0 +1,96 @@ +package sevenstar.marineleisure.forecast.domain; + +import java.time.LocalDate; +import java.time.LocalTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.TidePhase; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "scuba_forecast", uniqueConstraints = { + @UniqueConstraint(columnNames = {"spot_id", "forecast_date", "time_period"})}) +public class Scuba extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "spot_id", nullable = false) + private Long spotId; + + @Column(name = "forecast_date", nullable = false) + private LocalDate forecastDate; + + @Column(name = "time_period", length = 10, nullable = false) + @Enumerated(EnumType.STRING) + private TimePeriod timePeriod; + + private LocalTime sunrise; + private LocalTime sunset; + + @Column(name = "tide") + @Enumerated(EnumType.STRING) + private TidePhase tide; + + @Column(name = "total_index") + @Enumerated(EnumType.STRING) + private TotalIndex totalIndex; + + @Column(name = "wave_height_min") + private Float waveHeightMin; + + @Column(name = "wave_height_max") + private Float waveHeightMax; + + @Column(name = "sea_temp_min") + private Float seaTempMin; + + @Column(name = "sea_temp_max") + private Float seaTempMax; + + @Column(name = "current_speed_min") + private Float currentSpeedMin; + + @Column(name = "current_speed_max") + private Float currentSpeedMax; + + @Builder + public Scuba(Long spotId, LocalDate forecastDate, TimePeriod timePeriod, LocalTime sunrise, LocalTime sunset, + TidePhase tide, TotalIndex totalIndex, Float waveHeightMin, Float waveHeightMax, Float seaTempMin, + Float seaTempMax, Float currentSpeedMin, Float currentSpeedMax) { + this.spotId = spotId; + this.forecastDate = forecastDate; + this.timePeriod = timePeriod; + this.sunrise = sunrise; + this.sunset = sunset; + this.tide = tide; + this.totalIndex = totalIndex; + this.waveHeightMin = waveHeightMin; + this.waveHeightMax = waveHeightMax; + this.seaTempMin = seaTempMin; + this.seaTempMax = seaTempMax; + this.currentSpeedMin = currentSpeedMin; + this.currentSpeedMax = currentSpeedMax; + } + + public void updateSunriseAndSunset(LocalTime sunrise, LocalTime sunset) { + this.sunrise = sunrise; + this.sunset = sunset; + } +} diff --git a/src/main/java/sevenstar/marineleisure/forecast/domain/Surfing.java b/src/main/java/sevenstar/marineleisure/forecast/domain/Surfing.java new file mode 100644 index 00000000..2368f3a5 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/domain/Surfing.java @@ -0,0 +1,78 @@ +package sevenstar.marineleisure.forecast.domain; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "surfing_forecast", uniqueConstraints = { + @UniqueConstraint(columnNames = {"spot_id", "forecast_date", "time_period"})}) +public class Surfing extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "spot_id", nullable = false) + private Long spotId; + + @Column(name = "forecast_date", nullable = false) + private LocalDate forecastDate; + + @Column(name = "time_period", length = 10, nullable = false) + @Enumerated(EnumType.STRING) + private TimePeriod timePeriod; + + @Column(name = "wave_height") + private Float waveHeight; + + @Column(name = "wave_period") + private Float wavePeriod; + + @Column(name = "wind_speed") + private Float windSpeed; + + @Column(name = "sea_temp") + private Float seaTemp; + + @Column(name = "total_index") + @Enumerated(EnumType.STRING) + private TotalIndex totalIndex; + + @Column(name = "uv_index") + private Float uvIndex; + + @Builder + public Surfing(Long spotId, LocalDate forecastDate, TimePeriod timePeriod, Float waveHeight, Float wavePeriod, + Float windSpeed, Float seaTemp, TotalIndex totalIndex, Float uvIndex) { + this.spotId = spotId; + this.forecastDate = forecastDate; + this.timePeriod = timePeriod; + this.waveHeight = waveHeight; + this.wavePeriod = wavePeriod; + this.windSpeed = windSpeed; + this.seaTemp = seaTemp; + this.totalIndex = totalIndex; + this.uvIndex = uvIndex; + } + + public void updateUvIndex(Float uvIndex) { + this.uvIndex = uvIndex; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/forecast/repository/FishingRepository.java b/src/main/java/sevenstar/marineleisure/forecast/repository/FishingRepository.java new file mode 100644 index 00000000..02db7b13 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/repository/FishingRepository.java @@ -0,0 +1,125 @@ +package sevenstar.marineleisure.forecast.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.transaction.Transactional; +import sevenstar.marineleisure.forecast.domain.Fishing; +import sevenstar.marineleisure.spot.dto.projection.FishingReadProjection; +import sevenstar.marineleisure.spot.repository.ActivityRepository; + +public interface FishingRepository extends ActivityRepository { + @Query(value = """ + SELECT DISTINCT f.spotId FROM Fishing f + WHERE f.forecastDate BETWEEN :forecastDateAfter AND :forecastDateBefore + """) + List findByForecastDateBetween(@Param("forecastDateAfter") LocalDate forecastDateAfter, + @Param("forecastDateBefore") LocalDate forecastDateBefore); + + @Query(""" + SELECT + f.forecastDate AS forecastDate, + f.timePeriod AS timePeriod, + f.tide AS tide, + f.totalIndex AS totalIndex, + f.waveHeightMin AS waveHeightMin, + f.waveHeightMax AS waveHeightMax, + f.seaTempMin AS seaTempMin, + f.seaTempMax AS seaTempMax, + f.airTempMin AS airTempMin, + f.airTempMax AS airTempMax, + f.currentSpeedMin AS currentSpeedMin, + f.currentSpeedMax AS currentSpeedMax, + f.windSpeedMin AS windSpeedMin, + f.windSpeedMax AS windSpeedMax, + f.uvIndex AS uvIndex, + ft.id AS targetId, + ft.name AS targetName + + FROM Fishing f + LEFT JOIN FishingTarget ft ON f.targetId = ft.id + WHERE f.spotId = :spotId + AND f.forecastDate = :date + """) + List findForecastsWithFish(@Param("spotId") Long spotId, @Param("date") LocalDate date); + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO fishing_forecast ( + spot_id, target_id, forecast_date, time_period, tide, total_index, + wave_height_min, wave_height_max, sea_temp_min, sea_temp_max, + air_temp_min, air_temp_max, current_speed_min, current_speed_max, + wind_speed_min, wind_speed_max, created_at, updated_at + ) VALUES ( + :spotId, :targetId, :forecastDate, :timePeriod, :tide, :totalIndex, + :waveHeightMin, :waveHeightMax, :seaTempMin, :seaTempMax, + :airTempMin, :airTempMax, :currentSpeedMin, :currentSpeedMax, + :windSpeedMin, :windSpeedMax, NOW(), NOW() + ) + ON DUPLICATE KEY UPDATE + tide = VALUES(tide), + total_index = VALUES(total_index), + wave_height_min = VALUES(wave_height_min), + wave_height_max = VALUES(wave_height_max), + sea_temp_min = VALUES(sea_temp_min), + sea_temp_max = VALUES(sea_temp_max), + air_temp_min = VALUES(air_temp_min), + air_temp_max = VALUES(air_temp_max), + current_speed_min = VALUES(current_speed_min), + current_speed_max = VALUES(current_speed_max), + wind_speed_min = VALUES(wind_speed_min), + wind_speed_max = VALUES(wind_speed_max), + updated_at = NOW() + """, nativeQuery = true) + void upsertFishing( + @Param("spotId") Long spotId, + @Param("targetId") Long targetId, + @Param("forecastDate") LocalDate forecastDate, + @Param("timePeriod") String timePeriod, + @Param("tide") String tide, + @Param("totalIndex") String totalIndex, + @Param("waveHeightMin") Float waveHeightMin, + @Param("waveHeightMax") Float waveHeightMax, + @Param("seaTempMin") Float seaTempMin, + @Param("seaTempMax") Float seaTempMax, + @Param("airTempMin") Float airTempMin, + @Param("airTempMax") Float airTempMax, + @Param("currentSpeedMin") Float currentSpeedMin, + @Param("currentSpeedMax") Float currentSpeedMax, + @Param("windSpeedMin") Float windSpeedMin, + @Param("windSpeedMax") Float windSpeedMax + ); + + @Modifying + @Transactional + @Query(""" + UPDATE Fishing f + SET f.uvIndex = :uvIndex + WHERE f.spotId = :spotId + AND f.forecastDate = :forecastDate + """) + void updateUvIndex( + @Param("uvIndex") Float uvIndex, + @Param("spotId") Long spotId, + @Param("forecastDate") LocalDate forecastDate + ); + + Optional findFirstBySpotIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc( + Long spotId, + LocalDateTime startDateTime, + LocalDateTime endDateTime + ); + + Optional findTopByCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByTotalIndexDesc(LocalDateTime start, LocalDateTime end); + + Optional findBySpotIdAndCreatedAtBeforeOrderByCreatedAtDesc(Long spotId, LocalDateTime createdAtBefore); + + Optional findBySpotIdOrderByCreatedAt(Long spotId); +} diff --git a/src/main/java/sevenstar/marineleisure/forecast/repository/FishingTargetRepository.java b/src/main/java/sevenstar/marineleisure/forecast/repository/FishingTargetRepository.java new file mode 100644 index 00000000..6a28b7f2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/repository/FishingTargetRepository.java @@ -0,0 +1,11 @@ +package sevenstar.marineleisure.forecast.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import sevenstar.marineleisure.forecast.domain.FishingTarget; + +public interface FishingTargetRepository extends JpaRepository { + Optional findByName(String name); +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/forecast/repository/MudflatRepository.java b/src/main/java/sevenstar/marineleisure/forecast/repository/MudflatRepository.java new file mode 100644 index 00000000..3c2465ff --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/repository/MudflatRepository.java @@ -0,0 +1,85 @@ +package sevenstar.marineleisure.forecast.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.transaction.Transactional; +import sevenstar.marineleisure.forecast.domain.Mudflat; +import sevenstar.marineleisure.spot.repository.ActivityRepository; + +public interface MudflatRepository extends ActivityRepository { + @Query(value = """ + SELECT DISTINCT m.spotId FROM Mudflat m + WHERE m.forecastDate BETWEEN :forecastDateAfter AND :forecastDateBefore + """) + List findByForecastDateBetween(@Param("forecastDateAfter") LocalDate forecastDateAfter, + @Param("forecastDateBefore") LocalDate forecastDateBefore); + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO mudflat_forecast ( + spot_id, forecast_date, start_time, end_time, + air_temp_min, air_temp_max, wind_speed_min, wind_speed_max, + weather, total_index, created_at, updated_at + ) VALUES ( + :spotId, :forecastDate, :startTime, :endTime, + :airTempMin, :airTempMax, :windSpeedMin, :windSpeedMax, + :weather, :totalIndex, NOW(), NOW() + ) + ON DUPLICATE KEY UPDATE + start_time = VALUES(start_time), + end_time = VALUES(end_time), + air_temp_min = VALUES(air_temp_min), + air_temp_max = VALUES(air_temp_max), + wind_speed_min = VALUES(wind_speed_min), + wind_speed_max = VALUES(wind_speed_max), + weather = VALUES(weather), + total_index = VALUES(total_index), + updated_at = NOW() + """, nativeQuery = true) + void upsertMudflat( + @Param("spotId") Long spotId, + @Param("forecastDate") LocalDate forecastDate, + @Param("startTime") LocalTime startTime, + @Param("endTime") LocalTime endTime, + @Param("airTempMin") Float airTempMin, + @Param("airTempMax") Float airTempMax, + @Param("windSpeedMin") Float windSpeedMin, + @Param("windSpeedMax") Float windSpeedMax, + @Param("weather") String weather, + @Param("totalIndex") String totalIndex + ); + + @Modifying + @Transactional + @Query(""" + UPDATE Mudflat m + SET m.uvIndex = :uvIndex + WHERE m.spotId = :spotId + AND m.forecastDate = :forecastDate + """) + void updateUvIndex( + @Param("uvIndex") Float uvIndex, + @Param("spotId") Long spotId, + @Param("forecastDate") LocalDate forecastDate + ); + + Optional findFirstBySpotIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc( + Long spotId, + LocalDateTime startDateTime, + LocalDateTime endDateTime + ); + + Optional findTopByCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByTotalIndexDesc(LocalDateTime start, LocalDateTime end); + + Optional findBySpotIdAndCreatedAtBeforeOrderByCreatedAtDesc(Long spotId, LocalDateTime createdAtBefore); + +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/forecast/repository/ScubaRepository.java b/src/main/java/sevenstar/marineleisure/forecast/repository/ScubaRepository.java new file mode 100644 index 00000000..e24d2cb4 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/repository/ScubaRepository.java @@ -0,0 +1,86 @@ +package sevenstar.marineleisure.forecast.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.transaction.Transactional; +import sevenstar.marineleisure.forecast.domain.Scuba; +import sevenstar.marineleisure.spot.repository.ActivityRepository; + +public interface ScubaRepository extends ActivityRepository { + @Query(value = """ + SELECT DISTINCT s.spotId FROM Scuba s + WHERE s.forecastDate BETWEEN :forecastDateAfter AND :forecastDateBefore + """) + List findByForecastDateBetween(@Param("forecastDateAfter") LocalDate forecastDateAfter, + @Param("forecastDateBefore") LocalDate forecastDateBefore); + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO scuba_forecast ( + spot_id, forecast_date, time_period, tide, total_index, + wave_height_min, wave_height_max, sea_temp_min, sea_temp_max, + current_speed_min, current_speed_max, created_at, updated_at + ) VALUES ( + :spotId, :forecastDate, :timePeriod, :tide, :totalIndex, + :waveHeightMin, :waveHeightMax, :seaTempMin, :seaTempMax, + :currentSpeedMin, :currentSpeedMax, NOW(), NOW() + ) + ON DUPLICATE KEY UPDATE + tide = VALUES(tide), + total_index = VALUES(total_index), + wave_height_min = VALUES(wave_height_min), + wave_height_max = VALUES(wave_height_max), + sea_temp_min = VALUES(sea_temp_min), + sea_temp_max = VALUES(sea_temp_max), + current_speed_min = VALUES(current_speed_min), + current_speed_max = VALUES(current_speed_max), + updated_at = NOW() + """, nativeQuery = true) + void upsertScuba( + @Param("spotId") Long spotId, + @Param("forecastDate") LocalDate forecastDate, + @Param("timePeriod") String timePeriod, + @Param("tide") String tide, + @Param("totalIndex") String totalIndex, + @Param("waveHeightMin") Float waveHeightMin, + @Param("waveHeightMax") Float waveHeightMax, + @Param("seaTempMin") Float seaTempMin, + @Param("seaTempMax") Float seaTempMax, + @Param("currentSpeedMin") Float currentSpeedMin, + @Param("currentSpeedMax") Float currentSpeedMax + ); + + @Modifying + @Transactional + @Query(""" + UPDATE Scuba s + SET s.sunrise = :sunrise, + s.sunset = :sunset + WHERE s.spotId = :spotId + AND s.forecastDate = :forecastDate + """) + void updateSunriseAndSunset( + @Param("sunrise") LocalTime sunrise, + @Param("sunset") LocalTime sunset, + @Param("spotId") Long spotId, + @Param("forecastDate") LocalDate forecastDate + ); + + Optional findTopByCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByTotalIndexDesc(LocalDateTime start, LocalDateTime end); + + Optional findBySpotIdAndCreatedAtBeforeOrderByCreatedAtDesc(Long spotId, LocalDateTime createdAtBefore); + + Optional findFirstBySpotIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc( + Long spotId, + LocalDateTime startDateTime, + LocalDateTime endDateTime); +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/forecast/repository/SurfingRepository.java b/src/main/java/sevenstar/marineleisure/forecast/repository/SurfingRepository.java new file mode 100644 index 00000000..65f0d3f5 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/forecast/repository/SurfingRepository.java @@ -0,0 +1,85 @@ +package sevenstar.marineleisure.forecast.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.transaction.Transactional; +import sevenstar.marineleisure.forecast.domain.Surfing; +import sevenstar.marineleisure.spot.repository.ActivityRepository; + +public interface SurfingRepository extends ActivityRepository { + @Query(value = """ + SELECT DISTINCT s.spotId FROM Surfing s + WHERE s.forecastDate BETWEEN :forecastDateAfter AND :forecastDateBefore + """) + List findByForecastDateBetween(@Param("forecastDateAfter") LocalDate forecastDateAfter, + @Param("forecastDateBefore") LocalDate forecastDateBefore); + + @Query(""" + SELECT s FROM Surfing s + WHERE s.spotId = :spotId + AND s.forecastDate = :date + """) + List findSurfingForecasts(@Param("spotId") Long spotId, @Param("date") LocalDate date); + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO surfing_forecast ( + spot_id, forecast_date, time_period, wave_height, wave_period, + wind_speed, sea_temp, total_index, created_at, updated_at + ) VALUES ( + :spotId, :forecastDate, :timePeriod, :waveHeight, :wavePeriod, + :windSpeed, :seaTemp, :totalIndex, NOW(), NOW() + ) + ON DUPLICATE KEY UPDATE + wave_height = VALUES(wave_height), + wave_period = VALUES(wave_period), + wind_speed = VALUES(wind_speed), + sea_temp = VALUES(sea_temp), + total_index = VALUES(total_index), + updated_at = NOW() + """, nativeQuery = true) + void upsertSurfing( + @Param("spotId") Long spotId, + @Param("forecastDate") LocalDate forecastDate, + @Param("timePeriod") String timePeriod, + @Param("waveHeight") Float waveHeight, + @Param("wavePeriod") Float wavePeriod, + @Param("windSpeed") Float windSpeed, + @Param("seaTemp") Float seaTemp, + @Param("totalIndex") String totalIndex + ); + + @Modifying + @Transactional + @Query(""" + UPDATE Surfing s + SET s.uvIndex = :uvIndex + WHERE s.spotId = :spotId + AND s.forecastDate = :forecastDate + """) + void updateUvIndex( + @Param("uvIndex") Float uvIndex, + @Param("spotId") Long spotId, + @Param("forecastDate") LocalDate forecastDate + ); + + Optional findFirstBySpotIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByCreatedAtDesc( + Long spotId, + LocalDateTime startDateTime, + LocalDateTime endDateTime + ); + + Optional findTopByCreatedAtGreaterThanEqualAndCreatedAtLessThanOrderByTotalIndexDesc(LocalDateTime start, LocalDateTime end); + + Optional findBySpotIdAndCreatedAtBeforeOrderByCreatedAtDesc(Long spotId, LocalDateTime createdAtBefore); + + Optional findBySpotIdOrderByCreatedAt(Long spotId); +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/api/config/RestTemplateConfig.java b/src/main/java/sevenstar/marineleisure/global/api/config/RestTemplateConfig.java new file mode 100644 index 00000000..67ef19d8 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package sevenstar.marineleisure.global.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/config/properties/KhoaProperties.java b/src/main/java/sevenstar/marineleisure/global/api/config/properties/KhoaProperties.java new file mode 100644 index 00000000..9e2adac3 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/config/properties/KhoaProperties.java @@ -0,0 +1,87 @@ +package sevenstar.marineleisure.global.api.config.properties; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import lombok.Getter; +import sevenstar.marineleisure.global.enums.ActivityCategory; + +@Getter +@ConfigurationProperties(prefix = "api.khoa") +public class KhoaProperties { + + private final String baseUrl; + private final String serviceKey; + private final String type; + private final Path path; + + public KhoaProperties(String baseUrl, String serviceKey, String type, Path path) { + this.baseUrl = baseUrl; + this.serviceKey = serviceKey; + this.type = type; + this.path = path; + } + + @Getter + public static class Path { + private final String fishing; + private final String mudflat; + private final String diving; + private final String surfing; + + public Path(String fishing, String mudflat, String diving, String surfing) { + this.fishing = fishing; + this.mudflat = mudflat; + this.diving = diving; + this.surfing = surfing; + } + } + + public String getPath(ActivityCategory category) { + return switch (category) { + case FISHING -> path.getFishing(); + case MUDFLAT -> path.getMudflat(); + case SCUBA -> path.getDiving(); + case SURFING -> path.getSurfing(); + }; + } + + /** + * mudflat, diving, surfing api + * @param reqDate 요청일자 + * @param page + * @param size + * @return + */ + public MultiValueMap getParams(String reqDate, int page, int size) { + return getDefaultParams(String.format("%s00",reqDate), page, size); + } + + /** + * fishing api + * @param reqDate 요청일자 + * @param page + * @param size + * @param gubun + * @return + */ + public MultiValueMap getParams(String reqDate, int page, int size, String gubun) { + MultiValueMap defaultParams = getDefaultParams(reqDate, page, size); + defaultParams.add("gubun", URLEncoder.encode(gubun, StandardCharsets.UTF_8)); + return defaultParams; + } + + private MultiValueMap getDefaultParams(String reqDate, int page, int size) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("serviceKey", URLEncoder.encode(serviceKey, StandardCharsets.UTF_8)); + params.add("type", type); + params.add("reqDate", reqDate); + params.add("pageNo", String.valueOf(page)); + params.add("numOfRows", String.valueOf(size)); + return params; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/config/properties/OpenMeteoProperties.java b/src/main/java/sevenstar/marineleisure/global/api/config/properties/OpenMeteoProperties.java new file mode 100644 index 00000000..ea010b3c --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/config/properties/OpenMeteoProperties.java @@ -0,0 +1,45 @@ +package sevenstar.marineleisure.global.api.config.properties; + +import java.time.LocalDate; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import lombok.Getter; + +@Getter +@ConfigurationProperties(prefix = "api.openmeteo") +public class OpenMeteoProperties { + private final String baseUrl; + private final String timezone; + + public OpenMeteoProperties(String baseUrl, String timezone) { + this.baseUrl = baseUrl; + this.timezone = timezone; + } + + public MultiValueMap getSunriseSunsetParams(LocalDate startDate, LocalDate endDate, double latitude, + double longitude) { + return getDefaultParams("sunrise,sunset", startDate, endDate, latitude, longitude); + } + + public MultiValueMap getUvIndexParams(LocalDate startDate, LocalDate endDate, double latitude, + double longitude) { + return getDefaultParams("uv_index_max", startDate, endDate, latitude, longitude); + } + + private MultiValueMap getDefaultParams(String daily, LocalDate startDate, LocalDate endDate, + double latitude, + double longitude + ) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("latitude", String.valueOf(latitude)); + params.add("longitude", String.valueOf(longitude)); + params.add("daily", daily); + params.add("timezone", timezone); + params.add("start_date", startDate.toString()); + params.add("end_date", endDate.toString()); + return params; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/khoa/KhoaApiClient.java b/src/main/java/sevenstar/marineleisure/global/api/khoa/KhoaApiClient.java new file mode 100644 index 00000000..33763104 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/khoa/KhoaApiClient.java @@ -0,0 +1,65 @@ +package sevenstar.marineleisure.global.api.khoa; + +import java.net.URI; +import java.time.LocalDate; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.global.api.config.properties.KhoaProperties; +import sevenstar.marineleisure.global.api.khoa.dto.common.ApiResponse; +import sevenstar.marineleisure.global.api.khoa.dto.item.FishingItem; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.FishingType; +import sevenstar.marineleisure.global.utils.DateUtils; +import sevenstar.marineleisure.global.utils.UriBuilder; + +@Component +@RequiredArgsConstructor +public class KhoaApiClient { + private final RestTemplate restTemplate; + private final KhoaProperties khoaProperties; + + /** + * khoa api get 요청(갯벌체험, 서핑, 스쿠버다이빙) + * @param responseType response 타입 + * @param reqDate 요청 일자 + * @param page + * @param size + * @param category 활동 카테고리 + * @return response + * @param + */ + public ResponseEntity get(ParameterizedTypeReference responseType, LocalDate reqDate, int page, int size, + ActivityCategory category) { + if (category == ActivityCategory.FISHING) { + // TODO : handling exception + // throw new IllegalAccessException(); + } + URI uri = UriBuilder.buildQueryParameter(khoaProperties.getBaseUrl(), khoaProperties.getPath(category), + khoaProperties.getParams(DateUtils.formatTime(reqDate), page, size)); + return restTemplate.exchange(uri, HttpMethod.GET, null, responseType); + } + + /** + * khoa api get 요청(낚시) + * @param responseType response 타입 + * @param reqDate 요청 일자 + * @param page + * @param size + * @param gubun 선상 / 갯바위 중 하나 + * @return response + */ + public ResponseEntity> get( + ParameterizedTypeReference> responseType, LocalDate reqDate, int page, int size, + FishingType gubun) { + URI uri = UriBuilder.buildQueryParameter(khoaProperties.getBaseUrl(), + khoaProperties.getPath(ActivityCategory.FISHING), khoaProperties.getParams(DateUtils.formatTime(reqDate), page, size, gubun.getDescription())); + return restTemplate.exchange(uri, HttpMethod.GET, null, responseType); + } + +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/common/ApiResponse.java b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/common/ApiResponse.java new file mode 100644 index 00000000..2b9f538f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/common/ApiResponse.java @@ -0,0 +1,37 @@ + +package sevenstar.marineleisure.global.api.khoa.dto.common; + +import java.util.List; + +import lombok.Getter; + +@Getter +public class ApiResponse { + private Response response; + + @Getter + public static class Response { + private Header header; + private Body body; + } + + @Getter + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Getter + public static class Body { + private Items items; + private int pageNo; + private int numOfRows; + private int totalCount; + private String type; + } + + @Getter + public static class Items { + private List item; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/FishingItem.java b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/FishingItem.java new file mode 100644 index 00000000..038835b3 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/FishingItem.java @@ -0,0 +1,56 @@ +package sevenstar.marineleisure.global.api.khoa.dto.item; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import lombok.Getter; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.utils.DateUtils; + +@Getter +public class FishingItem implements KhoaItem { + private String seafsPstnNm; + private double lat; + private double lot; + private String predcYmd; + private String predcNoonSeCd; + private String seafsTgfshNm; + private int tdlvHrScr; + private float minWvhgt; + private float maxWvhgt; + private float minWtem; + private float maxWtem; + private float minArtmp; + private float maxArtmp; + private float minCrsp; + private float maxCrsp; + private float minWspd; + private float maxWspd; + private String totalIndex; + private double lastScr; + + @Override + public String getLocation() { + return seafsPstnNm; + } + + @Override + public BigDecimal getLatitude() { + return new BigDecimal(String.valueOf(lat)); + } + + @Override + public BigDecimal getLongitude() { + return new BigDecimal(String.valueOf(lot)); + } + + @Override + public ActivityCategory getCategory() { + return ActivityCategory.FISHING; + } + + @Override + public LocalDate getForecastDate() { + return DateUtils.parseDate(predcYmd); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/KhoaItem.java b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/KhoaItem.java new file mode 100644 index 00000000..d829ffff --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/KhoaItem.java @@ -0,0 +1,18 @@ +package sevenstar.marineleisure.global.api.khoa.dto.item; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import sevenstar.marineleisure.global.enums.ActivityCategory; + +public interface KhoaItem { + String getLocation(); + + BigDecimal getLatitude(); + + BigDecimal getLongitude(); + + ActivityCategory getCategory(); + + LocalDate getForecastDate(); +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/MudflatItem.java b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/MudflatItem.java new file mode 100644 index 00000000..74c630a3 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/MudflatItem.java @@ -0,0 +1,50 @@ +package sevenstar.marineleisure.global.api.khoa.dto.item; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import lombok.Getter; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.utils.DateUtils; + +@Getter +public class MudflatItem implements KhoaItem { + private String mdftExpcnVlgNm; // 마을 이름 + private double lat; // 위도 + private double lot; // 경도 + private String predcYmd; // 예측 날짜 + private String mdftExprnBgngTm; // 체험 시작 시간 + private String mdftExprnEndTm; // 체험 종료 시간 + private String minArtmp; // 최소 기온 + private String maxArtmp; // 최대 기온 + private String minWspd; // 최소 풍속 + private String maxWspd; // 최대 풍속 + private String weather; // 날씨 + private String totalIndex; // 체험지수 등급 + private double lastScr; // 점수 + + @Override + public String getLocation() { + return mdftExpcnVlgNm; + } + + @Override + public BigDecimal getLatitude() { + return new BigDecimal(String.valueOf(lat)); + } + + @Override + public BigDecimal getLongitude() { + return new BigDecimal(String.valueOf(lot)); + } + + @Override + public ActivityCategory getCategory() { + return ActivityCategory.MUDFLAT; + } + + @Override + public LocalDate getForecastDate() { + return DateUtils.parseDate(predcYmd); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/ScubaItem.java b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/ScubaItem.java new file mode 100644 index 00000000..cc3f61a1 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/ScubaItem.java @@ -0,0 +1,51 @@ +package sevenstar.marineleisure.global.api.khoa.dto.item; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import lombok.Getter; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.utils.DateUtils; + +@Getter +public class ScubaItem implements KhoaItem { + private String skscExpcnRgnNm; // 체험 지역명 + private double lat; // 위도 + private double lot; // 경도 + private String predcYmd; // 예보 날짜 + private String predcNoonSeCd; // 오전/오후/일 + private String tdlvHrCn; // 조위 정보 (소조기/대조기 등) + private String minWvhgt; // 최소 파고 + private String maxWvhgt; // 최대 파고 + private String minCrsp; // 최소 투명도 + private String maxCrsp; // 최대 투명도 + private String minWtem; // 최소 수온 + private String maxWtem; // 최대 수온 + private String totalIndex; // 체험 지수 + private double lastScr; // 점수 + + @Override + public String getLocation() { + return skscExpcnRgnNm; + } + + @Override + public BigDecimal getLatitude() { + return new BigDecimal(String.valueOf(lat)); + } + + @Override + public BigDecimal getLongitude() { + return new BigDecimal(String.valueOf(lot)); + } + + @Override + public ActivityCategory getCategory() { + return ActivityCategory.SCUBA; + } + + @Override + public LocalDate getForecastDate() { + return DateUtils.parseDate(predcYmd); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/SurfingItem.java b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/SurfingItem.java new file mode 100644 index 00000000..d51508d6 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/khoa/dto/item/SurfingItem.java @@ -0,0 +1,48 @@ +package sevenstar.marineleisure.global.api.khoa.dto.item; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import lombok.Getter; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.utils.DateUtils; + +@Getter +public class SurfingItem implements KhoaItem { + private String surfPlcNm; + private double lat; + private double lot; + private String predcYmd; + private String predcNoonSeCd; + private String avgWvhgt; + private String avgWvpd; + private String avgWspd; + private String avgWtem; + private String totalIndex; + private double lastScr; + + @Override + public String getLocation() { + return surfPlcNm; + } + + @Override + public BigDecimal getLatitude() { + return new BigDecimal(String.valueOf(lat)); + } + + @Override + public BigDecimal getLongitude() { + return new BigDecimal(String.valueOf(lot)); + } + + @Override + public ActivityCategory getCategory() { + return ActivityCategory.SURFING; + } + + @Override + public LocalDate getForecastDate() { + return DateUtils.parseDate(predcYmd); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/khoa/mapper/KhoaMapper.java b/src/main/java/sevenstar/marineleisure/global/api/khoa/mapper/KhoaMapper.java new file mode 100644 index 00000000..67598e37 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/khoa/mapper/KhoaMapper.java @@ -0,0 +1,40 @@ +package sevenstar.marineleisure.global.api.khoa.mapper; + +import org.locationtech.jts.geom.Point; + +import lombok.experimental.UtilityClass; +import sevenstar.marineleisure.forecast.domain.FishingTarget; +import sevenstar.marineleisure.global.api.khoa.dto.item.KhoaItem; +import sevenstar.marineleisure.global.enums.FishingType; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; + +@UtilityClass +public class KhoaMapper { + /** + * dto => OutdoorSpot + * @param item api로 요청받은 response의 실제 데이터 + * @param fishingType item이 FishingItem일 경우 ROCK / BOAT, 그 외일 경우 NONE + * @return + * @param FishingItem / ScubaItem / SurfingItem / MudflatItem 중 하나 + */ + public static OutdoorSpot toEntity(T item, FishingType fishingType, Point point) { + return OutdoorSpot.builder() + .name(item.getLocation()) + .category(item.getCategory()) + .type(fishingType) + .location(item.getLocation()) + .latitude(item.getLatitude()) + .longitude(item.getLongitude()) + .point(point) + .build(); + } + + /** + * dto => FishTarget Entity + * @param name fish 이름 + * @return + */ + public static FishingTarget toEntity(String name) { + return new FishingTarget(name); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/khoa/service/KhoaApiService.java b/src/main/java/sevenstar/marineleisure/global/api/khoa/service/KhoaApiService.java new file mode 100644 index 00000000..191ba2b1 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/khoa/service/KhoaApiService.java @@ -0,0 +1,180 @@ +package sevenstar.marineleisure.global.api.khoa.service; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.forecast.repository.FishingRepository; +import sevenstar.marineleisure.forecast.repository.FishingTargetRepository; +import sevenstar.marineleisure.forecast.repository.MudflatRepository; +import sevenstar.marineleisure.forecast.repository.ScubaRepository; +import sevenstar.marineleisure.forecast.repository.SurfingRepository; +import sevenstar.marineleisure.global.api.khoa.KhoaApiClient; +import sevenstar.marineleisure.global.api.khoa.dto.common.ApiResponse; +import sevenstar.marineleisure.global.api.khoa.dto.item.FishingItem; +import sevenstar.marineleisure.global.api.khoa.dto.item.KhoaItem; +import sevenstar.marineleisure.global.api.khoa.dto.item.MudflatItem; +import sevenstar.marineleisure.global.api.khoa.dto.item.ScubaItem; +import sevenstar.marineleisure.global.api.khoa.dto.item.SurfingItem; +import sevenstar.marineleisure.global.api.khoa.mapper.KhoaMapper; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.FishingType; +import sevenstar.marineleisure.global.enums.TidePhase; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.global.utils.DateUtils; +import sevenstar.marineleisure.global.utils.GeoUtils; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class KhoaApiService { + private final KhoaApiClient khoaApiClient; + private final OutdoorSpotRepository outdoorSpotRepository; + private final FishingRepository fishingRepository; + private final FishingTargetRepository fishingTargetRepository; + private final MudflatRepository mudflatRepository; + private final ScubaRepository scubaRepository; + private final SurfingRepository surfingRepository; + private final GeoUtils geoUtils; + + /** + * KHOA API를 통해 스쿠버, 낚시, 갯벌, 서핑 정보를 업데이트합니다. + *

+ * 해당 날짜 기준으로 7일치 데이터를 가져오며, 각 카테고리별로 데이터를 저장합니다. + */ + // TODO : 리팩토링 필요 + @Transactional + public void updateApi(LocalDate startDate, LocalDate endDate) { + + // scuba + List scubaItems = getKhoaApiData(new ParameterizedTypeReference<>() { + }, startDate, endDate, ActivityCategory.SCUBA); + + for (ScubaItem item : scubaItems) { + OutdoorSpot outdoorSpot = createOutdoorSpot(item, FishingType.NONE); + scubaRepository.upsertScuba(outdoorSpot.getId(), DateUtils.parseDate(item.getPredcYmd()), + TimePeriod.from(item.getPredcNoonSeCd()).name(), TidePhase.parse(item.getTdlvHrCn()).name(), + TotalIndex.fromDescription(item.getTotalIndex()).name(), Float.parseFloat(item.getMinWvhgt()), + Float.parseFloat(item.getMaxWvhgt()), Float.parseFloat(item.getMinWtem()), + Float.parseFloat(item.getMaxWtem()), Float.parseFloat(item.getMinCrsp()), + Float.parseFloat(item.getMaxCrsp())); + } + + // fishing + for (FishingType fishingType : FishingType.getFishingTypes()) { + for (LocalDate d = startDate; d.isBefore(endDate); d = d.plusDays(1)) { + List fishingItems = getKhoaApiData(new ParameterizedTypeReference<>() { + }, d, fishingType); + for (FishingItem item : fishingItems) { + OutdoorSpot outdoorSpot = createOutdoorSpot(item, fishingType); + Long targetId = item.getSeafsTgfshNm() == null ? null : + fishingTargetRepository.findByName(item.getSeafsTgfshNm()) + .orElseGet(() -> fishingTargetRepository.save(KhoaMapper.toEntity(item.getSeafsTgfshNm()))) + .getId(); + fishingRepository.upsertFishing(outdoorSpot.getId(), targetId, + DateUtils.parseDate(item.getPredcYmd()), TimePeriod.from(item.getPredcNoonSeCd()).name(), + TidePhase.parse(item.getTdlvHrScr()).name(), + TotalIndex.fromDescription(item.getTotalIndex()).name(), item.getMinWvhgt(), item.getMaxWvhgt(), + item.getMinWtem(), item.getMaxWtem(), item.getMinArtmp(), item.getMinArtmp(), item.getMinCrsp(), + item.getMaxCrsp(), item.getMinWspd(), item.getMaxWspd()); + } + } + } + + // surfing + List surfingItems = getKhoaApiData(new ParameterizedTypeReference<>() { + }, startDate, endDate, ActivityCategory.SURFING); + + for (SurfingItem item : surfingItems) { + OutdoorSpot outdoorSpot = createOutdoorSpot(item, FishingType.NONE); + + surfingRepository.upsertSurfing(outdoorSpot.getId(), DateUtils.parseDate(item.getPredcYmd()), + TimePeriod.from(item.getPredcNoonSeCd()).name(), Float.parseFloat(item.getAvgWvhgt()), + Float.parseFloat(item.getAvgWvpd()), Float.parseFloat(item.getAvgWspd()), + Float.parseFloat(item.getAvgWtem()), TotalIndex.fromDescription(item.getTotalIndex()).name()); + } + + // mudflat + List mudflatItems = getKhoaApiData(new ParameterizedTypeReference<>() { + }, startDate, endDate, ActivityCategory.MUDFLAT); + + for (MudflatItem item : mudflatItems) { + OutdoorSpot outdoorSpot = createOutdoorSpot(item, FishingType.NONE); + + mudflatRepository.upsertMudflat(outdoorSpot.getId(), DateUtils.parseDate(item.getPredcYmd()), + LocalTime.parse(item.getMdftExprnBgngTm()), LocalTime.parse(item.getMdftExprnEndTm()), + Float.parseFloat(item.getMinArtmp()), Float.parseFloat(item.getMaxArtmp()), + Float.parseFloat(item.getMinWspd()), Float.parseFloat(item.getMaxWspd()), item.getWeather(), + TotalIndex.fromDescription(item.getTotalIndex()).name()); + } + } + + @Transactional + public OutdoorSpot createOutdoorSpot(KhoaItem item, FishingType fishingType) { + return outdoorSpotRepository.findByLatitudeAndLongitudeAndCategory(item.getLatitude(), item.getLongitude(), + item.getCategory()) + .orElseGet(() -> outdoorSpotRepository.save( + KhoaMapper.toEntity(item, fishingType, geoUtils.createPoint(item.getLatitude(), item.getLongitude())))); + } + + private List getKhoaApiData(ParameterizedTypeReference> responseType, + LocalDate startDate, + LocalDate endDate, ActivityCategory category) { + List result = new ArrayList<>(); + + int page = 1; + int size = 300; + while (true) { + ResponseEntity> response = khoaApiClient.get(responseType, startDate, page++, size, + category); + for (T item : response.getBody().getResponse().getBody().getItems().getItem()) { + if (!item.getForecastDate().isBefore(endDate)) { + continue; + } + result.add(item); + } + if (response.getBody().getResponse().getBody().getPageNo() * response.getBody() + .getResponse() + .getBody() + .getNumOfRows() > response.getBody().getResponse().getBody().getTotalCount()) { + break; + } + } + return result; + } + + private List getKhoaApiData(ParameterizedTypeReference> responseType, + LocalDate date, FishingType fishingType) { + List result = new ArrayList<>(); + + int page = 1; + int size = 300; + while (true) { + ResponseEntity> response = khoaApiClient.get(responseType, date, page++, + size, + fishingType); + result.addAll(response.getBody().getResponse().getBody().getItems().getItem()); + if (response.getBody().getResponse().getBody().getPageNo() * response.getBody() + .getResponse() + .getBody() + .getNumOfRows() > response.getBody().getResponse().getBody().getTotalCount()) { + break; + } + } + + return result; + } +} + diff --git a/src/main/java/sevenstar/marineleisure/global/api/openmeteo/OpenMeteoApiClient.java b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/OpenMeteoApiClient.java new file mode 100644 index 00000000..8aad561a --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/OpenMeteoApiClient.java @@ -0,0 +1,42 @@ +package sevenstar.marineleisure.global.api.openmeteo; + +import java.net.URI; +import java.time.LocalDate; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.global.api.config.properties.OpenMeteoProperties; +import sevenstar.marineleisure.global.api.openmeteo.dto.common.OpenMeteoReadResponse; +import sevenstar.marineleisure.global.api.openmeteo.dto.item.SunTimeItem; +import sevenstar.marineleisure.global.api.openmeteo.dto.item.UvIndexItem; +import sevenstar.marineleisure.global.utils.UriBuilder; + +@Component +@RequiredArgsConstructor +public class OpenMeteoApiClient { + private final RestTemplate restTemplate; + private final OpenMeteoProperties openMeteoProperties; + + public ResponseEntity> getSunTimes( + ParameterizedTypeReference> responseType, + LocalDate startDate, LocalDate endDate, double latitude, double longitude) { + URI uri = UriBuilder.buildQueryParameter(openMeteoProperties.getBaseUrl(), + openMeteoProperties.getSunriseSunsetParams(startDate, endDate, latitude, longitude)); + + return restTemplate.exchange(uri, HttpMethod.GET, null, responseType); + } + + public ResponseEntity> getUvIndex( + ParameterizedTypeReference> responseType, + LocalDate startDate, LocalDate endDate, double latitude, double longitude) { + URI uri = UriBuilder.buildQueryParameter(openMeteoProperties.getBaseUrl(), + openMeteoProperties.getUvIndexParams(startDate, endDate, latitude, longitude)); + + return restTemplate.exchange(uri, HttpMethod.GET, null, responseType); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/common/OpenMeteoReadResponse.java b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/common/OpenMeteoReadResponse.java new file mode 100644 index 00000000..b2fc7f2a --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/common/OpenMeteoReadResponse.java @@ -0,0 +1,50 @@ +package sevenstar.marineleisure.global.api.openmeteo.dto.common; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; + +@Getter +public class OpenMeteoReadResponse { + private double latitude; + private double longitude; + @JsonProperty("generationtime_ms") + private double generationtimeMs; + @JsonProperty("utc_offset_seconds") + private int utcOffsetSeconds; + private String timezone; + @JsonProperty("timezone_abbreviation") + private String timezoneAbbreviation; + private int elevation; + @JsonProperty("daily_units") + private DailyUnits dailyUnits; + private T daily; + + public OpenMeteoReadResponse(double latitude, double longitude, double generationtimeMs, int utcOffsetSeconds, + String timezone, String timezoneAbbreviation, int elevation, DailyUnits dailyUnits, T daily) { + this.latitude = latitude; + this.longitude = longitude; + this.generationtimeMs = generationtimeMs; + this.utcOffsetSeconds = utcOffsetSeconds; + this.timezone = timezone; + this.timezoneAbbreviation = timezoneAbbreviation; + this.elevation = elevation; + this.dailyUnits = dailyUnits; + this.daily = daily; + } + + @Getter + public static class DailyUnits { + private String time; + private String sunrise; + private String sunset; + private String uvIndexMax; + + public DailyUnits(String time, String sunrise, String sunset, String uvIndexMax) { + this.time = time; + this.sunrise = sunrise; + this.sunset = sunset; + this.uvIndexMax = uvIndexMax; + } + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/item/SunTimeItem.java b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/item/SunTimeItem.java new file mode 100644 index 00000000..f2ab153d --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/item/SunTimeItem.java @@ -0,0 +1,20 @@ +package sevenstar.marineleisure.global.api.openmeteo.dto.item; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Getter; + +@Getter +public class SunTimeItem { + private List time; + private List sunrise; + private List sunset; + + public SunTimeItem(List time, List sunrise, List sunset) { + this.time = time; + this.sunrise = sunrise; + this.sunset = sunset; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/item/UvIndexItem.java b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/item/UvIndexItem.java new file mode 100644 index 00000000..1ffb916c --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/item/UvIndexItem.java @@ -0,0 +1,20 @@ +package sevenstar.marineleisure.global.api.openmeteo.dto.item; + +import java.time.LocalDate; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; + +@Getter +public class UvIndexItem { + private List time; + @JsonProperty("uv_index_max") + private List uvIndexMax; + + public UvIndexItem(List time, List uvIndexMax) { + this.time = time; + this.uvIndexMax = uvIndexMax; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/service/OpenMeteoService.java b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/service/OpenMeteoService.java new file mode 100644 index 00000000..bd515b4d --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/openmeteo/dto/service/OpenMeteoService.java @@ -0,0 +1,101 @@ +package sevenstar.marineleisure.global.api.openmeteo.dto.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.forecast.repository.FishingRepository; +import sevenstar.marineleisure.forecast.repository.MudflatRepository; +import sevenstar.marineleisure.forecast.repository.ScubaRepository; +import sevenstar.marineleisure.forecast.repository.SurfingRepository; +import sevenstar.marineleisure.global.api.openmeteo.OpenMeteoApiClient; +import sevenstar.marineleisure.global.api.openmeteo.dto.common.OpenMeteoReadResponse; +import sevenstar.marineleisure.global.api.openmeteo.dto.item.SunTimeItem; +import sevenstar.marineleisure.global.api.openmeteo.dto.item.UvIndexItem; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OpenMeteoService { + private final OpenMeteoApiClient openMeteoApiClient; + private final OutdoorSpotRepository outdoorSpotRepository; + private final FishingRepository fishingRepository; + private final MudflatRepository mudflatRepository; + private final ScubaRepository scubaRepository; + private final SurfingRepository surfingRepository; + + // TODO : exception , refactoring + @Transactional + public void updateApi(LocalDate startDate, LocalDate endDate) { + // update fishing uvIndex + for (Long spotId : fishingRepository.findByForecastDateBetween(startDate, endDate)) { + OutdoorSpot outdoorSpot = outdoorSpotRepository.findById(spotId).orElseThrow(); + UvIndexItem uvIndex = getUvIndex(startDate, endDate, outdoorSpot.getLatitude().doubleValue(), + outdoorSpot.getLongitude().doubleValue()); + for (int i = 0; i < uvIndex.getTime().size(); i++) { + Float uvIndexValue = uvIndex.getUvIndexMax().get(i); + LocalDate date = uvIndex.getTime().get(i); + fishingRepository.updateUvIndex(uvIndexValue, spotId, date); + } + } + + // update mudflat uvIndex + for (Long spotId : mudflatRepository.findByForecastDateBetween(startDate, endDate)) { + OutdoorSpot outdoorSpot = outdoorSpotRepository.findById(spotId).orElseThrow(); + UvIndexItem uvIndex = getUvIndex(startDate, endDate, outdoorSpot.getLatitude().doubleValue(), + outdoorSpot.getLongitude().doubleValue()); + for (int i = 0; i < uvIndex.getTime().size(); i++) { + Float uvIndexValue = uvIndex.getUvIndexMax().get(i); + LocalDate date = uvIndex.getTime().get(i); + mudflatRepository.updateUvIndex(uvIndexValue, spotId, date); + } + } + + // update scuba sunrise and sunset + for (Long spotId : scubaRepository.findByForecastDateBetween(startDate, endDate)) { + OutdoorSpot outdoorSpot = outdoorSpotRepository.findById(spotId).orElseThrow(); + SunTimeItem sunTimeItem = getSunTimes(startDate, endDate, outdoorSpot.getLatitude().doubleValue(), + outdoorSpot.getLongitude().doubleValue()); + for (int i = 0; i < sunTimeItem.getTime().size(); i++) { + LocalDateTime sunrise = sunTimeItem.getSunrise().get(i); + LocalDateTime sunset = sunTimeItem.getSunset().get(i); + LocalDate date = sunTimeItem.getTime().get(i); + scubaRepository.updateSunriseAndSunset(sunrise.toLocalTime(), sunset.toLocalTime(), spotId, date); + } + } + + // update surfing uvIndex + for (Long spotId : surfingRepository.findByForecastDateBetween(startDate, endDate)) { + OutdoorSpot outdoorSpot = outdoorSpotRepository.findById(spotId).orElseThrow(); + UvIndexItem uvIndex = getUvIndex(startDate, endDate, outdoorSpot.getLatitude().doubleValue(), + outdoorSpot.getLongitude().doubleValue()); + for (int i = 0; i < uvIndex.getTime().size(); i++) { + Float uvIndexValue = uvIndex.getUvIndexMax().get(i); + LocalDate date = uvIndex.getTime().get(i); + surfingRepository.updateUvIndex(uvIndexValue, spotId, date); + } + } + + } + + private SunTimeItem getSunTimes(LocalDate startDate, LocalDate endDate, double latitude, double longitude) { + ResponseEntity> response = openMeteoApiClient.getSunTimes( + new ParameterizedTypeReference<>() { + }, startDate, endDate, latitude, longitude); + return response.getBody().getDaily(); + } + + private UvIndexItem getUvIndex(LocalDate startDate, LocalDate endDate, double latitude, double longitude) { + ResponseEntity> response = openMeteoApiClient.getUvIndex( + new ParameterizedTypeReference<>() { + }, startDate, endDate, latitude, longitude); + return response.getBody().getDaily(); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerConfig.java b/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerConfig.java new file mode 100644 index 00000000..e987e5bc --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerConfig.java @@ -0,0 +1,9 @@ +package sevenstar.marineleisure.global.api.scheduler; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { +} diff --git a/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerService.java b/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerService.java new file mode 100644 index 00000000..86229537 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/api/scheduler/SchedulerService.java @@ -0,0 +1,39 @@ +package sevenstar.marineleisure.global.api.scheduler; + +import java.time.LocalDate; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.global.api.khoa.service.KhoaApiService; +import sevenstar.marineleisure.global.api.openmeteo.dto.service.OpenMeteoService; +import sevenstar.marineleisure.spot.repository.SpotViewQuartileRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class SchedulerService { + public static final int MAX_UPDATE_DAY = 3; + private final KhoaApiService khoaApiService; + private final OpenMeteoService openMeteoService; + private final SpotViewQuartileRepository spotViewQuartileRepository; + + /** + * 앞으로의 스케줄링 전략에 의해 수정될 부분입니다. + * @author guwnoong + */ + @Scheduled(initialDelay = 0, fixedDelay = 86400000) + @Transactional + public void scheduler() { + LocalDate today = LocalDate.now(); + LocalDate endDate = today.plusDays(MAX_UPDATE_DAY); + khoaApiService.updateApi(today, endDate); + openMeteoService.updateApi(today, endDate); + spotViewQuartileRepository.upsertQuartile(); + log.info("=== update data ==="); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/config/JpaAuditingConfig.java b/src/main/java/sevenstar/marineleisure/global/config/JpaAuditingConfig.java new file mode 100644 index 00000000..b1984f20 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package sevenstar.marineleisure.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/sevenstar/marineleisure/global/config/RedisConfig.java b/src/main/java/sevenstar/marineleisure/global/config/RedisConfig.java new file mode 100644 index 00000000..bbff69b4 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/config/RedisConfig.java @@ -0,0 +1,97 @@ +package sevenstar.marineleisure.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 설정 + * 토큰 블랙리스트 관리를 위한 Redis 설정을 제공합니다. + */ +@Configuration +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password:}") + private String redisPassword; + + @Value("${spring.redis.ssl:false}") + private boolean redisSsl; + + /** + * RedisConnectionFactory 빈 등록 + * application.yml의 spring.redis 설정을 바탕으로 StandaloneConfiguration 및 SSL 옵션을 구성합니다. + */ + @Bean + public RedisConnectionFactory redisConnectionFactory() { + // Standalone 설정 + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort); + if (redisPassword != null && !redisPassword.isBlank()) { + config.setPassword(RedisPassword.of(redisPassword)); + } + + // Lettuce 클라이언트 설정 + LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettuceClientConfiguration.builder(); + if (redisSsl) { + builder.useSsl().disablePeerVerification(); + } + LettuceClientConfiguration clientConfig = builder.build(); + + return new LettuceConnectionFactory(config, clientConfig); + } + + /** + * RedisTemplate 빈 등록 + * 키는 String, 값은 JSON으로 직렬화하여 저장합니다. + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + return template; + } +} + +/** + * Redis 설정 + * 토큰 블랙리스트 관리를 위한 Redis 설정을 제공합니다. + */ +//@Configuration +//public class RedisConfig { +// +// /** +// * Redis 연결 팩토리 +// * 기본 설정으로 localhost:6379에 연결합니다. +// */ +// @Bean +// public RedisConnectionFactory redisConnectionFactory() { +// return new LettuceConnectionFactory(); +// } +// +// /** +// * Redis 템플릿 +// * 키는 문자열, 값은 JSON으로 직렬화하여 저장합니다. +// */ +// @Bean +// public RedisTemplate redisTemplate() { +// RedisTemplate template = new RedisTemplate<>(); +// template.setConnectionFactory(redisConnectionFactory()); +// template.setKeySerializer(new StringRedisSerializer()); +// template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); +// return template; +// } +//} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/config/SecurityConfig.java b/src/main/java/sevenstar/marineleisure/global/config/SecurityConfig.java new file mode 100644 index 00000000..72efd070 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/config/SecurityConfig.java @@ -0,0 +1,96 @@ +package sevenstar.marineleisure.global.config; + +import java.util.Arrays; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.global.jwt.JwtAuthenticationEntryPoint; +import sevenstar.marineleisure.global.jwt.JwtAuthenticationFilter; +import sevenstar.marineleisure.global.jwt.JwtTokenProvider; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Value("${jwt.use-cookie:true}") + private boolean useCookie; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 허용할 엔드 포인트 + .authorizeHttpRequests(auth -> auth + // (1) 정적 리소스 + // .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + // (2) SPA 진입점(root & index.html) + .requestMatchers(HttpMethod.GET, "/", "/index.html").permitAll() + // (3) 인증 API + OAuth 콜백(GET, POST) + .requestMatchers("/auth/**").permitAll() + .requestMatchers(HttpMethod.GET, "/oauth/**").permitAll() + .requestMatchers(HttpMethod.POST, "/oauth/**").permitAll() + // (5) H2 콘솔 + .requestMatchers("/h2-console/**").permitAll() + // Map에는 인증이 필수가 아닙니다 + .requestMatchers("/map/**").permitAll() + // 위험경보관련 API는 인증이 필요하지 않습니다. + .requestMatchers("/alerts/**").permitAll() + .requestMatchers("/activities/**").permitAll() + //Meeting 조회에는 인증이 필요하지 않습니다. + .requestMatchers(HttpMethod.GET, "/meetings").permitAll() + .requestMatchers(HttpMethod.GET, "/meetings/{id}").permitAll() + // (6) 나머지는 인증 필요 + .anyRequest().authenticated() + ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable); + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + // 와일드카드 대신 명시적인 오리진 목록 사용 + config.setAllowedOrigins(Arrays.asList( + "https://your-frontend-domain.com", // 프로덕션 환경 프론트엔드 도메인 + "http://localhost:3000", // 개발 환경 프론트엔드 도메인 + "http://localhost:5173" // 현재 프론트엔드 개발 환경 + )); + + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Requested-With")); + + // jwt.use-cookie 설정에 따라 credentials 설정 변경 + // useCookie=true 일 때만 allowCredentials=true (쿠키 사용) + config.setAllowCredentials(useCookie); + config.setMaxAge(3600L); // 프리플라이트 요청 캐싱 (1시간) + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/config/WebClientConfig.java b/src/main/java/sevenstar/marineleisure/global/config/WebClientConfig.java new file mode 100644 index 00000000..fc8dc69a --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package sevenstar.marineleisure.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + @Bean + public WebClient webClient() { + return WebClient.builder().build(); + } + +} diff --git a/src/main/java/sevenstar/marineleisure/global/domain/BaseEntity.java b/src/main/java/sevenstar/marineleisure/global/domain/BaseEntity.java new file mode 100644 index 00000000..5e1fa076 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/domain/BaseEntity.java @@ -0,0 +1,26 @@ +package sevenstar.marineleisure.global.domain; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + protected LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + protected LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/domain/BaseResponse.java b/src/main/java/sevenstar/marineleisure/global/domain/BaseResponse.java new file mode 100644 index 00000000..c9fcf601 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/domain/BaseResponse.java @@ -0,0 +1,32 @@ +package sevenstar.marineleisure.global.domain; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import sevenstar.marineleisure.global.exception.enums.ErrorCode; + +public record BaseResponse( + int code, + String message, + T body +) { + public static ResponseEntity> success(T body) { + return ResponseEntity.ok(new BaseResponse<>(200, "Success", body)); + } + + public static ResponseEntity> success(HttpStatus status, T body){ + return ResponseEntity.status(status).body(new BaseResponse<>(status.value(), status.getReasonPhrase(), body)); + } + + public static ResponseEntity> error(ErrorCode errorCode) { + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(new BaseResponse<>(errorCode.getCode(), errorCode.getMessage(), null)); + } + + public static ResponseEntity> error(ErrorCode errorCode,String customMessage) { + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(new BaseResponse<>(errorCode.getCode(), customMessage, null)); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/ActivityCategory.java b/src/main/java/sevenstar/marineleisure/global/enums/ActivityCategory.java new file mode 100644 index 00000000..24431f12 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/ActivityCategory.java @@ -0,0 +1,19 @@ +package sevenstar.marineleisure.global.enums; + +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.CommonErrorCode; + +public enum ActivityCategory { + FISHING, + SURFING, + SCUBA, + MUDFLAT; + + public static ActivityCategory parse(String category) { + try { + return valueOf(category); + } catch (IllegalArgumentException e) { + throw new CustomException(CommonErrorCode.INVALID_PARAMETER); + } + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/DensityLevel.java b/src/main/java/sevenstar/marineleisure/global/enums/DensityLevel.java new file mode 100644 index 00000000..081e9c65 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/DensityLevel.java @@ -0,0 +1,16 @@ +package sevenstar.marineleisure.global.enums; + +public enum DensityLevel { + LOW("저밀도"), + HIGH("고밀도"); + + private final String description; + + DensityLevel(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/enums/FishingType.java b/src/main/java/sevenstar/marineleisure/global/enums/FishingType.java new file mode 100644 index 00000000..3774c0cd --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/FishingType.java @@ -0,0 +1,23 @@ +package sevenstar.marineleisure.global.enums; + +import java.util.List; + +public enum FishingType { + ROCK("갯바위"), + BOAT("선상"), + NONE("없음"); + + private final String description; + + FishingType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + public static List getFishingTypes() { + return List.of(ROCK, BOAT); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/HlCode.java b/src/main/java/sevenstar/marineleisure/global/enums/HlCode.java new file mode 100644 index 00000000..aab572fa --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/HlCode.java @@ -0,0 +1,6 @@ +package sevenstar.marineleisure.global.enums; + +public enum HlCode { + HIGH, + LOW +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/MeetingRole.java b/src/main/java/sevenstar/marineleisure/global/enums/MeetingRole.java new file mode 100644 index 00000000..f514b391 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/MeetingRole.java @@ -0,0 +1,6 @@ +package sevenstar.marineleisure.global.enums; + +public enum MeetingRole { + HOST, + GUEST +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/MeetingStatus.java b/src/main/java/sevenstar/marineleisure/global/enums/MeetingStatus.java new file mode 100644 index 00000000..29dce03e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/MeetingStatus.java @@ -0,0 +1,12 @@ +package sevenstar.marineleisure.global.enums; + +public enum MeetingStatus { + //모집중 + RECRUITING, + //진행중 + ONGOING, + //다참 + FULL, + //완료 + COMPLETED +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/MemberStatus.java b/src/main/java/sevenstar/marineleisure/global/enums/MemberStatus.java new file mode 100644 index 00000000..632b8866 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/MemberStatus.java @@ -0,0 +1,6 @@ +package sevenstar.marineleisure.global.enums; + +public enum MemberStatus { + ACTIVE, + EXPIRED +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/TidePhase.java b/src/main/java/sevenstar/marineleisure/global/enums/TidePhase.java new file mode 100644 index 00000000..e1a676ce --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/TidePhase.java @@ -0,0 +1,40 @@ +package sevenstar.marineleisure.global.enums; + +public enum TidePhase { + SPRING_TIDE("대조기"), + Intermediate_Tide("중조기"), + NEAP_TIDE("소조기"); + + private String description; + + TidePhase(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + public static TidePhase parse(String origin) { + for (TidePhase value : values()) { + if (value.getDescription().equals(origin)) { + return value; + } + } + // TODO : exception handling + throw new IllegalArgumentException( + "Invalid TidePhase description: " + origin); + + } + + public static TidePhase parse(int tideIndex) { + if (tideIndex >= 70) { + return SPRING_TIDE; + } + if (tideIndex >= 30) { + return Intermediate_Tide; + } + return NEAP_TIDE; + } + +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/TimePeriod.java b/src/main/java/sevenstar/marineleisure/global/enums/TimePeriod.java new file mode 100644 index 00000000..8bb90127 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/TimePeriod.java @@ -0,0 +1,24 @@ +package sevenstar.marineleisure.global.enums; + +import lombok.Getter; + +@Getter +public enum TimePeriod { + AM("오전"), + PM("오후"); + private String description; + + TimePeriod(String description) { + this.description = description; + } + + public static TimePeriod from(String value) { + for (TimePeriod timePeriod : TimePeriod.values()) { + if (timePeriod.getDescription().equals(value)) { + return timePeriod; + } + } + throw new IllegalArgumentException("Invalid TimePeriod value: " + value); + } + +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/TotalIndex.java b/src/main/java/sevenstar/marineleisure/global/enums/TotalIndex.java new file mode 100644 index 00000000..d1e2a7ab --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/TotalIndex.java @@ -0,0 +1,29 @@ +package sevenstar.marineleisure.global.enums; + +public enum TotalIndex { + VERY_BAD("매우나쁨"), + BAD("나쁨"), + NORMAL("보통"), + GOOD("좋음"), + VERY_GOOD("매우좋음"), + NONE("불가능"); // 갯벌 체험에서는 "체험 불가" , 스쿠버 다이빙에서 "서비스기간 아님" + + private final String description; + + public String getDescription() { + return description; + } + + TotalIndex(String description) { + this.description = description; + } + + public static TotalIndex fromDescription(String description) { + for (TotalIndex index : values()) { + if (index.getDescription().equals(description)) { + return index; + } + } + return NONE; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/enums/ToxicityLevel.java b/src/main/java/sevenstar/marineleisure/global/enums/ToxicityLevel.java new file mode 100644 index 00000000..5952a7d6 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/enums/ToxicityLevel.java @@ -0,0 +1,18 @@ +package sevenstar.marineleisure.global.enums; + +public enum ToxicityLevel { + NONE("무해성"), + LOW("약독성"), + HIGH("강독성"), + LETHAL("맹독성"); + + private final String description; + + ToxicityLevel(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/exception/CustomException.java b/src/main/java/sevenstar/marineleisure/global/exception/CustomException.java new file mode 100644 index 00000000..3fd1fd2d --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/exception/CustomException.java @@ -0,0 +1,19 @@ +package sevenstar.marineleisure.global.exception; + +import lombok.Getter; +import sevenstar.marineleisure.global.exception.enums.ErrorCode; + +@Getter +public class CustomException extends RuntimeException { + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String customMessage) { + super(customMessage); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/exception/enums/ActivityErrorCode.java b/src/main/java/sevenstar/marineleisure/global/exception/enums/ActivityErrorCode.java new file mode 100644 index 00000000..d09810f8 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/exception/enums/ActivityErrorCode.java @@ -0,0 +1,37 @@ +package sevenstar.marineleisure.global.exception.enums; + +import org.springframework.http.HttpStatus; + +/** + * 8XXX + */ +public enum ActivityErrorCode implements ErrorCode { + // 84XX: + INVALID_ACTIVITY(8400, HttpStatus.NOT_FOUND, "옳바르지 않은 활동입니다."), + WEATHER_NOT_FOUND(8404, HttpStatus.NOT_FOUND, "정보가 없습니다."); + + ActivityErrorCode(int code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } + + private final int code; + private final HttpStatus httpStatus; + private final String message; + + @Override + public int getCode() { + return 0; + } + + @Override + public HttpStatus getHttpStatus() { + return null; + } + + @Override + public String getMessage() { + return ""; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/exception/enums/AlertErrorCode.java b/src/main/java/sevenstar/marineleisure/global/exception/enums/AlertErrorCode.java new file mode 100644 index 00000000..749fcb2f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/exception/enums/AlertErrorCode.java @@ -0,0 +1,36 @@ +package sevenstar.marineleisure.global.exception.enums; + +import org.springframework.http.HttpStatus; + +/** + * 5XXX + */ + +public enum AlertErrorCode implements ErrorCode { + ALERT_NOT_FOUND(5404, HttpStatus.NOT_FOUND, "경보를 찾을수 없습니다."); + + AlertErrorCode(int code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } + + private final int code; + private final HttpStatus httpStatus; + private final String message; + + @Override + public int getCode() { + return code; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/exception/enums/CommonErrorCode.java b/src/main/java/sevenstar/marineleisure/global/exception/enums/CommonErrorCode.java new file mode 100644 index 00000000..33d2e575 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/exception/enums/CommonErrorCode.java @@ -0,0 +1,36 @@ +package sevenstar.marineleisure.global.exception.enums; + +import org.springframework.http.HttpStatus; + +public enum CommonErrorCode implements ErrorCode { + // 9XXX: 공통 + INTERNET_SERVER_ERROR(9500, HttpStatus.INTERNAL_SERVER_ERROR, "서버에 문제가 발생했습니다."), + + INVALID_PARAMETER(9400, HttpStatus.BAD_REQUEST, "잘못된 파라미터 전송되었습니다."); + + + private final int code; + private final HttpStatus httpStatus; + private final String message; + + CommonErrorCode(int code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public int getCode() { + return code; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/exception/enums/ErrorCode.java b/src/main/java/sevenstar/marineleisure/global/exception/enums/ErrorCode.java new file mode 100644 index 00000000..7549fcc2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/exception/enums/ErrorCode.java @@ -0,0 +1,11 @@ +package sevenstar.marineleisure.global.exception.enums; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + int getCode(); + + HttpStatus getHttpStatus(); + + String getMessage(); +} diff --git a/src/main/java/sevenstar/marineleisure/global/exception/enums/FavoriteErrorCode.java b/src/main/java/sevenstar/marineleisure/global/exception/enums/FavoriteErrorCode.java new file mode 100644 index 00000000..fa7c0857 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/exception/enums/FavoriteErrorCode.java @@ -0,0 +1,33 @@ +package sevenstar.marineleisure.global.exception.enums; + +import org.springframework.http.HttpStatus; + +public enum FavoriteErrorCode implements ErrorCode { + INVALID_FAVORITE_PARAMETER(6400, HttpStatus.BAD_REQUEST, "즐겨찾기 id의 형식과 범위가 맞지 않습니다."), + FORBIDDEN_FAVORITE_ACCESS(6403, HttpStatus.FORBIDDEN, "해당 즐겨찾기에 접근할 권한이 없습니다."), + FAVORITE_NOT_FOUND(6404, HttpStatus.NOT_FOUND, "즐겨찾기를 찾을 수 없습니다."); + private final int code; + private final HttpStatus httpStatus; + private final String message; + + FavoriteErrorCode(int code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public int getCode() { + return this.code; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/exception/enums/MemberErrorCode.java b/src/main/java/sevenstar/marineleisure/global/exception/enums/MemberErrorCode.java new file mode 100644 index 00000000..4bddfe87 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/exception/enums/MemberErrorCode.java @@ -0,0 +1,46 @@ +package sevenstar.marineleisure.global.exception.enums; + +import org.springframework.http.HttpStatus; + +public enum MemberErrorCode implements ErrorCode { + // 14XX: Client errors + SECURITY_VALIDATION_FAILED(1403, HttpStatus.FORBIDDEN, "보안 검증에 실패했습니다."), + REFRESH_TOKEN_MISSING(1401, HttpStatus.UNAUTHORIZED, "리프레시 토큰이 없습니다."), + REFRESH_TOKEN_INVALID(1402, HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."), + MEMBER_NOT_FOUND(1404, HttpStatus.NOT_FOUND, "찾을수 없는 회원입니다."), + + // 15XX: Service errors + KAKAO_LOGIN_ERROR(1500, HttpStatus.INTERNAL_SERVER_ERROR, "카카오 로그인 처리 중 오류가 발생했습니다."), + KAKAO_LOGIN_CANCELED(1503, HttpStatus.BAD_REQUEST, "사용자가 카카오 로그인을 취소했습니다."), + TOKEN_REFRESH_ERROR(1501, HttpStatus.INTERNAL_SERVER_ERROR, "토큰 재발급 중 오류가 발생했습니다."), + LOGOUT_ERROR(1502, HttpStatus.INTERNAL_SERVER_ERROR, "로그아웃 중 오류가 발생했습니다."), + + // 1XXX: 기타 + FEATURE_NOT_SUPPORTED(1001, HttpStatus.NOT_IMPLEMENTED, "지원하지 않는 기능입니다."); + + private final int code; + private final HttpStatus httpStatus; + private final String message; + + MemberErrorCode(int code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public int getCode() { + return code; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } + +} diff --git a/src/main/java/sevenstar/marineleisure/global/exception/enums/SpotErrorCode.java b/src/main/java/sevenstar/marineleisure/global/exception/enums/SpotErrorCode.java new file mode 100644 index 00000000..6b5779f0 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/exception/enums/SpotErrorCode.java @@ -0,0 +1,34 @@ +package sevenstar.marineleisure.global.exception.enums; + +import org.springframework.http.HttpStatus; + +public enum SpotErrorCode implements ErrorCode { + // 3XXX: spot + SPOT_NOT_FOUND(3404, HttpStatus.NOT_FOUND, "스팟을 찾을 수 없음"), + DUPLICATE_FAVORITE(3409, HttpStatus.CONFLICT, "이미 즐겨찾기한 스팟"); + + private final int code; + private final HttpStatus httpStatus; + private final String message; + + SpotErrorCode(int code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public int getCode() { + return code; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/exception/handler/GlobalExceptionHandler.java b/src/main/java/sevenstar/marineleisure/global/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..51fd13cb --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,36 @@ +package sevenstar.marineleisure.global.exception.handler; + +import java.util.stream.Collectors; + +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import sevenstar.marineleisure.global.domain.BaseResponse; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.CommonErrorCode; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity> handleCustomException(CustomException ex) { + return BaseResponse.error(ex.getErrorCode()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex) { + ex.printStackTrace(); + return BaseResponse.error(CommonErrorCode.INTERNET_SERVER_ERROR); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return BaseResponse.error(CommonErrorCode.INVALID_PARAMETER, message); + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/jwt/BlacklistTokenCleanupService.java b/src/main/java/sevenstar/marineleisure/global/jwt/BlacklistTokenCleanupService.java new file mode 100644 index 00000000..b2c32894 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/jwt/BlacklistTokenCleanupService.java @@ -0,0 +1,36 @@ +package sevenstar.marineleisure.global.jwt; + +import java.time.LocalDateTime; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BlacklistTokenCleanupService { + private final BlacklistedRefreshTokenRepository repository; + + @Scheduled(cron = "0 0 0 * * ?") + @Transactional + public void cleanupExpiredTokens() { + LocalDateTime now = LocalDateTime.now(); + log.info("Starting cleanup of expired blacklisted refresh tokens at {}", now); + try { + repository.deleteExpiredTokens(now); + log.info("Finished cleanup of expired blacklisted refresh tokens at {}", now); + } catch (Exception e) { + log.error("Error while cleaning up expired blacklisted refresh tokens at {}", now, e); + throw new RuntimeException( + "Error while cleaning up expired blacklisted refresh tokens at " + now + ": " + e.getMessage()); + } finally { + log.info("Cleanup of expired blacklisted refresh tokens at {} finished", now); + } + } + +} + diff --git a/src/main/java/sevenstar/marineleisure/global/jwt/BlacklistedRefreshToken.java b/src/main/java/sevenstar/marineleisure/global/jwt/BlacklistedRefreshToken.java new file mode 100644 index 00000000..3e678c28 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/jwt/BlacklistedRefreshToken.java @@ -0,0 +1,38 @@ +package sevenstar.marineleisure.global.jwt; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "blacklisted_refresh_tokens", + indexes = { + @Index(name = "idx_blacklisted_refresh_tokens_jti", columnList = "jti") + }) +@Getter +@NoArgsConstructor +public class BlacklistedRefreshToken extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String jti; + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false) + private LocalDateTime expiryDate; + + @Builder + public BlacklistedRefreshToken(String jti, Long memberId, LocalDateTime expiryDate) { + this.jti = jti; + this.memberId = memberId; + this.expiryDate = expiryDate; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/jwt/BlacklistedRefreshTokenRepository.java b/src/main/java/sevenstar/marineleisure/global/jwt/BlacklistedRefreshTokenRepository.java new file mode 100644 index 00000000..e1076d2a --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/jwt/BlacklistedRefreshTokenRepository.java @@ -0,0 +1,21 @@ +package sevenstar.marineleisure.global.jwt; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface BlacklistedRefreshTokenRepository extends JpaRepository { + Optional findByJti(String jti); + + boolean existsByJti(String jti); + + @Modifying + @Query("DELETE FROM BlacklistedRefreshToken b WHERE b.expiryDate < :now") + void deleteExpiredTokens(@Param("now") LocalDateTime now); +} diff --git a/src/main/java/sevenstar/marineleisure/global/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/sevenstar/marineleisure/global/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..b1fd432f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,50 @@ +package sevenstar.marineleisure.global.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * JWT 인증 예외 처리 + * 인증되지 않은 사용자가 보호된 리소스에 접근할 때 호출됩니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) + throws IOException, ServletException { + + log.error("Unauthorized error: {}", authException.getMessage()); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + Map errorDetails = new HashMap<>(); + errorDetails.put("status", HttpStatus.UNAUTHORIZED.value()); + errorDetails.put("error", "Unauthorized"); + errorDetails.put("message", "인증이 필요합니다. 로그인 후 이용해주세요."); + errorDetails.put("path", request.getRequestURI()); + + objectMapper.writeValue(response.getOutputStream(), errorDetails); + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/jwt/JwtAuthenticationFilter.java b/src/main/java/sevenstar/marineleisure/global/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..abdc9d64 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +package sevenstar.marineleisure.global.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT 인증 필터 + * 모든 요청에 대해 JWT 토큰을 검증하고 인증 정보를 설정합니다. + */ +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // 요청 헤더에서 JWT 토큰 추출 + String token = resolveToken(request); + + // 토큰이 유효한 경우 인증 정보 설정 + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("Set Authentication to security context for '{}', uri: {}", + authentication.getName(), request.getRequestURI()); + } else { + log.debug("No valid JWT token found, uri: {}", request.getRequestURI()); + } + + filterChain.doFilter(request, response); + } + + /** + * 요청 헤더에서 JWT 토큰 추출 + * Authorization 헤더에서 Bearer 토큰을 추출합니다. + * @param request api 요청 + * @return null + */ + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/jwt/JwtTokenProvider.java b/src/main/java/sevenstar/marineleisure/global/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..9509cd03 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/jwt/JwtTokenProvider.java @@ -0,0 +1,248 @@ +package sevenstar.marineleisure.global.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import sevenstar.marineleisure.member.domain.Member; + +import javax.crypto.SecretKey; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final BlacklistedRefreshTokenRepository blacklistedRefreshTokenRepository; + private final RedisBlacklistedTokenRepository redisBlacklistedTokenRepository; + + @Value("${jwt.secret:defaultSecretKeyForDevelopmentEnvironmentOnly}") + private String secretKey; + + @Value("${jwt.access-token-validity-in-seconds:300}") // 5분 + private long accessTokenValidityInSeconds; + + @Value("${jwt.refresh-token-validity-in-seconds:86400}") // 24시간 + private long refreshTokenValidityInSeconds; + + private SecretKey key; + + @PostConstruct + public void init() { + // byte[] decodedKey = Base64.getDecoder().decode(secretKey); + // secretKey를 기반으로 Key 객체를 초기화 (최소 32byte 필요!) + byte[] decodedKey = secretKey.getBytes(StandardCharsets.UTF_8); + this.key = Keys.hmacShaKeyFor(decodedKey); + } + + public String createAccessToken(Member member) { + Date now = new Date(); + Date exp = new Date(now.getTime() + accessTokenValidityInSeconds * 1000); + + return Jwts.builder() + .subject(member.getId().toString()) // 회원 ID + .claim("token_type", "access") + .claim("memberId", member.getId()) + .claim("email", member.getEmail()) + .issuedAt(now) + .expiration(exp) + .signWith(key) + .compact(); + } + + public String createRefreshToken(Member member) { + String jti = UUID.randomUUID().toString(); + Date now = new Date(); + Date validity = new Date(now.getTime() + refreshTokenValidityInSeconds * 1000); + + String refreshToken = Jwts.builder() + .subject(member.getId().toString()) + .claim("email", member.getEmail()) + .claim("memberId", member.getId()) + .claim("token_type", "refresh") + .claim("jti", jti) + .issuedAt(now) + .expiration(validity) + .signWith(key) + .compact(); + + return refreshToken; + } + + public boolean validateRefreshToken(String refreshToken) { + // 1. 먼저 Redis에서 토큰이 블랙리스트에 있는지 확인 (더 빠른 인메모리 확인) + if (redisBlacklistedTokenRepository.isBlacklisted(refreshToken)) { + log.info("Refresh Token is blacklisted in Redis: {}", refreshToken); + return false; + } + + try { + // 2. 토큰 서명 및 만료 확인 + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(refreshToken); + + // 3. 토큰이 유효하면 JTI로 RDB에서 블랙리스트 확인 + String jti = getJti(refreshToken); + if (blacklistedRefreshTokenRepository.existsByJti(jti)) { + log.info("Refresh Token is blacklisted in RDB by JTI: {}", jti); + return false; + } + + return true; + } catch (ExpiredJwtException e) { + log.info("Refresh Token Expired : {}", e.getMessage()); + return false; + } catch (Exception e) { + log.error("Refresh Token Validation Error : {}", e.getMessage()); + return false; + } + } + + public Long getMemberId(String refreshToken) { + try { + Jws jwt = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(refreshToken); + return jwt.getPayload().get("memberId", Long.class); + } catch (ExpiredJwtException e) { + log.error("Expired JWT token while getting memberId: {}", e.getMessage()); + throw new IllegalArgumentException("Refresh token has expired"); + } catch (Exception e) { + log.error("Error getting memberId from refresh token: {}", e.getMessage()); + throw new IllegalArgumentException("Invalid refresh token"); + } + } + + /** + * 리프레시 토큰을 블랙리스트에 추가 + * + * @param refreshToken 블랙리스트에 추가할 리프레시 토큰 + */ + public void blacklistRefreshToken(String refreshToken) { + try { + // 토큰 파싱을 한 번만 수행하여 예외 처리 간소화 + Claims claims; + try { + claims = Jwts.parser().verifyWith(key) + .build() + .parseSignedClaims(refreshToken) + .getPayload(); + } catch (ExpiredJwtException e) { + log.info("Expired refresh token, no need to blacklist: {}", e.getMessage()); + return; // 이미 만료된 토큰은 블랙리스트에 추가할 필요 없음 + } catch (Exception e) { + log.error("Invalid refresh token, cannot blacklist: {}", e.getMessage()); + return; // 유효하지 않은 토큰은 블랙리스트에 추가할 수 없음 + } + + String jti = claims.get("jti", String.class); + Long memberId = claims.get("memberId", Long.class); + Date expirationDate = claims.getExpiration(); + long expirationTime = expirationDate.getTime() - System.currentTimeMillis(); + + // Redis에 토큰 블랙리스트 추가 + if (expirationTime > 0) { + redisBlacklistedTokenRepository.addToBlacklist(refreshToken, expirationTime); + } + + LocalDateTime expiration = Instant.ofEpochMilli(expirationDate.getTime()) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + BlacklistedRefreshToken blacklistedToken = BlacklistedRefreshToken.builder() + .jti(jti) + .memberId(memberId) + .expiryDate(expiration) + .build(); + + blacklistedRefreshTokenRepository.save(blacklistedToken); + log.info("Refresh Token Blacklisted : {}", refreshToken); + } catch (Exception e) { + log.error("Refresh Token Blacklist Error : {}", e.getMessage()); + throw new RuntimeException("Refresh Token Blacklist Error : " + e.getMessage()); + } + } + + public String getJti(String refreshToken) { + try { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(refreshToken) + .getPayload() + .get("jti", String.class); + } catch (ExpiredJwtException e) { + log.error("Expired JWT token while getting JTI: {}", e.getMessage()); + throw new IllegalArgumentException("Refresh token has expired"); + } catch (Exception e) { + log.error("Error getting JTI from refresh token: {}", e.getMessage()); + throw new IllegalArgumentException("Invalid refresh token"); + } + } + + /** + * JWT 토큰 유효성 검증 + * 토큰이 만료되었거나 서명이 유효하지 않은 경우 false를 반환합니다. + * 액세스 토큰은 블랙리스트 확인을 하지 않습니다. + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + return true; + } catch (ExpiredJwtException e) { + log.info("Token Expired : {}", e.getMessage()); + return false; + } catch (Exception e) { + log.error("Token Validation Error : {}", e.getMessage()); + return false; + } + } + + /** + * JWT 토큰에서 인증 정보 추출 + * 토큰에서 사용자 ID와 이메일을 추출하여 Authentication 객체를 생성합니다. + */ + public Authentication getAuthentication(String token) { + Claims claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + + Long memberId = claims.get("memberId", Long.class); + String email = claims.get("email", String.class); + + // 사용자 정보와 권한을 포함한 Authentication 객체 생성 + // Custom UserPrincipal 생성 + UserPrincipal principal = new UserPrincipal(memberId, email, null); + + return new UsernamePasswordAuthenticationToken( + principal, + null, + null + ); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/jwt/RedisBlacklistedTokenRepository.java b/src/main/java/sevenstar/marineleisure/global/jwt/RedisBlacklistedTokenRepository.java new file mode 100644 index 00000000..63239e50 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/jwt/RedisBlacklistedTokenRepository.java @@ -0,0 +1,48 @@ +package sevenstar.marineleisure.global.jwt; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +/** + * Redis 기반 토큰 블랙리스트 저장소 + * 만료된 토큰을 Redis에 저장하여 관리합니다. + */ +@Repository +@RequiredArgsConstructor +public class RedisBlacklistedTokenRepository { + + private final RedisTemplate redisTemplate; + private static final String KEY_PREFIX = "blacklisted:token:"; + + /** + * 토큰을 블랙리스트에 추가 + * + * @param token 블랙리스트에 추가할 토큰 + * @param expirationTimeMillis 토큰 만료 시간(밀리초) + */ + public void addToBlacklist(String token, long expirationTimeMillis) { + String key = KEY_PREFIX + token; + redisTemplate.opsForValue().set(key, "blacklisted"); + + // 토큰 만료 시간만큼만 Redis에 저장 + if (expirationTimeMillis > 0) { + redisTemplate.expire(key, expirationTimeMillis, TimeUnit.MILLISECONDS); + } + } + + /** + * 토큰이 블랙리스트에 있는지 확인 + * + * @param token 확인할 토큰 + * @return 블랙리스트에 있으면 true, 없으면 false + */ + public boolean isBlacklisted(String token) { + String key = KEY_PREFIX + token; + Boolean exists = redisTemplate.hasKey(key); + return exists != null && exists; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/jwt/UserPrincipal.java b/src/main/java/sevenstar/marineleisure/global/jwt/UserPrincipal.java new file mode 100644 index 00000000..358bf7cd --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/jwt/UserPrincipal.java @@ -0,0 +1,62 @@ +package sevenstar.marineleisure.global.jwt; + +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import lombok.Builder; + +/** + * Custom UserDetails implementation to hold authenticated user's ID, email, and authorities. + */ +@Builder +public class UserPrincipal implements UserDetails { + private final Long id; + private final String email; + private final Collection authorities; + + public UserPrincipal(Long id, String email, Collection authorities) { + this.id = id; + this.email = email; + this.authorities = authorities; + } + + public Long getId() { + return id; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return null; // OAuth 인증이므로 패스워드 사용 안 함 + } + + @Override + public String getUsername() { + return email; // principal로 이메일 사용 + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/swagger/SwaggerController.java b/src/main/java/sevenstar/marineleisure/global/swagger/SwaggerController.java new file mode 100644 index 00000000..2f524fee --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/swagger/SwaggerController.java @@ -0,0 +1,73 @@ +package sevenstar.marineleisure.global.swagger; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import sevenstar.marineleisure.global.domain.BaseResponse; +import sevenstar.marineleisure.global.exception.enums.CommonErrorCode; +import sevenstar.marineleisure.global.exception.enums.MemberErrorCode; + +/** + * Swagger 사용 예제 + * @author gunwoong + */ +@RestController +@RequestMapping("/swagger") +@Tag(name = "hello swagger", description = "Swagger 테스트 API") +public class SwaggerController { + + @Operation(summary = "Swagger get test", description = "Swagger의 GET 요청 테스트 (No Parameter)") + @ApiResponse(responseCode = "200", description = "성공", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @GetMapping("/get") + public ResponseEntity> testGet() { + return BaseResponse.success(new SwaggerTestResponse("swagger username", "swagger password")); + } + + @Operation(summary = "Swagger get test", description = "Swagger의 GET 요청 테스트 (One Parameter)") + @GetMapping("/get/{username}") + public ResponseEntity> testGet( + @Parameter(description = "사용자 ID", example = "testUsername") @PathVariable(name = "username") String username + ) { + return BaseResponse.success(new SwaggerTestResponse(username, "swagger password")); + } + + @Operation(summary = "Swagger post test", description = "Swagger의 POST 요청 테스트 (request body)") + @PostMapping("/post") + public ResponseEntity> testPost( + @RequestBody SwaggerTestRequest swaggerTestRequest + ) { + return BaseResponse.success( + new SwaggerTestResponse(swaggerTestRequest.getUsername(), swaggerTestRequest.getPassword())); + } + + @Operation(summary = "Swagger post test", description = "Swagger의 POST 요청 테스트 (model attribute)") + @PostMapping(value = "/post", consumes = "multipart/form-data") + public ResponseEntity> uploadProfile( + @ModelAttribute SwaggerTestRequest swaggerTestRequest + ) { + return BaseResponse.success( + new SwaggerTestResponse(swaggerTestRequest.getUsername(), swaggerTestRequest.getPassword())); + } + + @Operation(summary = "사용자 삭제") + @DeleteMapping("/{id}") + public ResponseEntity> deleteUser( + @Parameter(description = "삭제할 사용자 ID", example = "1") @PathVariable Long id + ) { + return BaseResponse.error(CommonErrorCode.INTERNET_SERVER_ERROR); + } + +} diff --git a/src/main/java/sevenstar/marineleisure/global/swagger/SwaggerTestRequest.java b/src/main/java/sevenstar/marineleisure/global/swagger/SwaggerTestRequest.java new file mode 100644 index 00000000..8f7726ff --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/swagger/SwaggerTestRequest.java @@ -0,0 +1,19 @@ +package sevenstar.marineleisure.global.swagger; + +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "swagger 테스트용 request", example = "testuser") +public class SwaggerTestRequest { + @Schema(description = "사용자 이름", example = "testuser") + private final String username; + @Schema(description = "이메일 주소", example = "test@gmail.com") + private final String email; + @Schema(description = "비밀번호", example = "1234") + private final String password; + @Schema(description = "이미지 파일", type = "string", format = "binary") + private final MultipartFile file; +} diff --git a/src/main/java/sevenstar/marineleisure/global/swagger/SwaggerTestResponse.java b/src/main/java/sevenstar/marineleisure/global/swagger/SwaggerTestResponse.java new file mode 100644 index 00000000..8ad03f8d --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/swagger/SwaggerTestResponse.java @@ -0,0 +1,13 @@ +package sevenstar.marineleisure.global.swagger; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "swagger 테스트용 response", example = "testuser") +public class SwaggerTestResponse { + @Schema(description = "사용자 이름", example = "testuser") + private final String username; + @Schema(description = "비밀번호", example = "1234") + private final String password; +} diff --git a/src/main/java/sevenstar/marineleisure/global/swagger/example/swagger-docs.md b/src/main/java/sevenstar/marineleisure/global/swagger/example/swagger-docs.md new file mode 100644 index 00000000..5028b655 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/swagger/example/swagger-docs.md @@ -0,0 +1,58 @@ +# ✅ Swagger 이용 가이드 + +--- + +## 🔧 1. Swagger(OpenAPI) 설정 방법 + +### 📦 Gradle 의존성 추가 + +```groovy +dependencies { + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' +} +``` + +버전은 최신 안정 버전을 확인(https://search.maven.org/search?q=springdoc-openapi) + +## 🌐 2. Swagger UI 접속 방법 + +Spring Boot 서버 실행 후 아래 주소로 접속: + +```bash +http://localhost:8080/swagger-ui/index.html +``` + +## 🧩 3. API 문서 자동 생성 원리 + +| 구성 요소 | 설명 | +| ------------------------------------------------ | -------------------------------- | +| `@RestController`, `@RequestMapping` | API 엔드포인트 자동 인식 | +| `@RequestBody`, `@PathVariable`, `@RequestParam` | 파라미터 자동 문서화 | +| `@Schema`, `@Operation`, `@Parameter` | Swagger 문서 커스터마이징용 어노테이션 | +| DTO 클래스 | 요청(Request)/응답(Response) 스키마 정의용 | + + +## 🧪 4. 실전 예제 + +SwaggerController.java, Swagger 패키지안의 예제 참조 바람 + + +## 📁 5. 자주 사용하는 Swagger 어노테이션 +| 어노테이션 | 설명 | +| -------------- | ------------------------- | +| `@Operation` | API 메서드에 대한 설명 추가 | +| `@Schema` | DTO 필드에 대한 설명, 예제 지정 | +| `@Parameter` | `@RequestParam` 등 파라미터 설명 | +| `@RequestBody` | 요청 본문에 대한 설명 (대부분 생략 가능) | + +## 🧼 6. 주의할 점 +- MultipartFile은 @RequestPart 또는 @ModelAttribute로 작성해야 Swagger에서 제대로 보임 +- record나 @Getter 기반 DTO에 @Schema 어노테이션은 잘 붙어야 UI에서 인식됨 +- 너무 과도한 Swagger 어노테이션은 피하고, 필요한 곳만 문서화 + +## 📌 기타 +Swagger 문서는 OpenAPI 3.0 스펙을 따릅니다. + +API 명세가 자동으로 관리되므로 Postman 문서 작성이 불필요합니다. + +필요시 Swagger JSON 명세를 export하여 API 문서 자동 생성 도구와 연동 가능합니다. diff --git a/src/main/java/sevenstar/marineleisure/global/util/CookieUtil.java b/src/main/java/sevenstar/marineleisure/global/util/CookieUtil.java new file mode 100644 index 00000000..e703f053 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/util/CookieUtil.java @@ -0,0 +1,114 @@ +package sevenstar.marineleisure.global.util; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * 쿠키 관리 유틸리티 + * 쿠키 생성, 조회, 삭제 로직을 담당합니다. + * jwt.use-cookie 설정에 따라 쿠키 설정을 다르게 적용합니다. + */ +@Component +public class CookieUtil { + + @Value("${jwt.use-cookie:true}") + private boolean useCookie; + + /** + * 리프레시 토큰 쿠키 생성 + * jwt.use-cookie 설정에 따라 쿠키 설정이 달라집니다. + * - useCookie=false: secure=false, sameSite=Lax + * - useCookie=true: secure=true, sameSite=None + * + * @param refreshToken 리프레시 토큰 + * @return 생성된 쿠키 + */ + public Cookie createRefreshTokenCookie(String refreshToken) { + boolean useSecureCookie = useCookie; + + // Create a standard Cookie + Cookie cookie = new Cookie("refresh_token", refreshToken); + cookie.setHttpOnly(true); + cookie.setSecure(useSecureCookie); + cookie.setPath("/"); + cookie.setMaxAge((int) Duration.ofDays(14).toSeconds()); + + // Set SameSite attribute + if (useSecureCookie) { + cookie.setAttribute("SameSite", "None"); + } else { + cookie.setAttribute("SameSite", "Lax"); + } + + return cookie; + } + + /** + * 리프레시 토큰 쿠키 삭제 + * jwt.use-cookie 설정에 따라 쿠키 설정이 달라집니다. + * + * @return 삭제용 쿠키 + */ + public Cookie deleteRefreshTokenCookie() { + boolean useSecureCookie = useCookie; + + Cookie cookie = new Cookie("refresh_token", ""); + cookie.setHttpOnly(true); + cookie.setSecure(useSecureCookie); + cookie.setPath("/"); + cookie.setMaxAge(0); // 쿠키 즉시 만료 + + // Set SameSite attribute + if (useSecureCookie) { + cookie.setAttribute("SameSite", "None"); + } else { + cookie.setAttribute("SameSite", "Lax"); + } + + return cookie; + } + + /** + * 쿠키 조회 + * + * @param request HTTP 요청 + * @param name 쿠키 이름 + * @return 찾은 쿠키 또는 null + */ + public Cookie getCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return cookie; + } + } + } + return null; + } + + /** + * 쿠키 추가 + * + * @param response HTTP 응답 + * @param cookie 추가할 쿠키 + */ + public void addCookie(HttpServletResponse response, Cookie cookie) { + response.addCookie(cookie); + } + + /** + * 현재 설정이 쿠키를 사용하는지 여부 반환 + * + * @return jwt.use-cookie 설정값 + */ + public boolean isUsingCookie() { + return useCookie; + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/util/CurrentUserUtil.java b/src/main/java/sevenstar/marineleisure/global/util/CurrentUserUtil.java new file mode 100644 index 00000000..d349debd --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/util/CurrentUserUtil.java @@ -0,0 +1,66 @@ +package sevenstar.marineleisure.global.util; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.MemberErrorCode; +import sevenstar.marineleisure.global.jwt.UserPrincipal; + +/** + * 현재 인증된 사용자의 정보를 쉽게 접근할 수 있는 유틸리티 클래스 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CurrentUserUtil { + + /** + * 현재 인증된 사용자의 ID를 반환합니다. + * 인증되지 않은 경우 IllegalStateException을 발생시킵니다. + * + * @return 현재 인증된 사용자의 ID + * @throws IllegalStateException 인증되지 않은 경우 + */ + public static Long getCurrentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() || + !(authentication.getPrincipal() instanceof UserPrincipal)) { + throw new CustomException(MemberErrorCode.MEMBER_NOT_FOUND); + } + + UserPrincipal principal = (UserPrincipal)authentication.getPrincipal(); + return principal.getId(); + } + + /** + * 현재 인증된 사용자의 이메일을 반환합니다. + * 인증되지 않은 경우 IllegalStateException을 발생시킵니다. + * + * @return 현재 인증된 사용자의 이메일 + * @throws IllegalStateException 인증되지 않은 경우 + */ + public static String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() || + !(authentication.getPrincipal() instanceof UserPrincipal)) { + throw new IllegalStateException("인증된 사용자가 아닙니다."); + } + + UserPrincipal principal = (UserPrincipal)authentication.getPrincipal(); + return principal.getUsername(); // UserPrincipal에서 getUsername()은 이메일을 반환 + } + + /** + * 사용자가 인증되었는지 확인합니다. + * + * @return 인증된 경우 true, 그렇지 않은 경우 false + */ + public static boolean isAuthenticated() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null && authentication.isAuthenticated() && + authentication.getPrincipal() instanceof UserPrincipal; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/util/StateEncryptionUtil.java b/src/main/java/sevenstar/marineleisure/global/util/StateEncryptionUtil.java new file mode 100644 index 00000000..bfa7daf5 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/util/StateEncryptionUtil.java @@ -0,0 +1,90 @@ +package sevenstar.marineleisure.global.util; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Base64; + +/** + * OAuth 상태 값 암호화/복호화 유틸리티 + * 세션 없이 상태 값을 안전하게 관리하기 위한 유틸리티. + */ +@Component +public class StateEncryptionUtil { + + @Value("${oauth.state.encryption.secret:defaultSecretKey}") + private String secretKey; + + /** + * 상태 값을 암호화합니다. + * + * @param state 암호화할 상태 값 + * @return 암호화된 상태 값 (Base64 인코딩) + */ + public String encryptState(String state) { + try { + SecretKeySpec keySpec = generateKey(secretKey); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + byte[] encrypted = cipher.doFinal(state.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt state", e); + } + } + + /** + * 암호화된 상태 값을 복호화. + * + * @param encryptedState 암호화된 상태 값 (Base64 인코딩) + * @return 복호화된 상태 값 + */ + public String decryptState(String encryptedState) { + try { + SecretKeySpec keySpec = generateKey(secretKey); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, keySpec); + byte[] decoded = Base64.getUrlDecoder().decode(encryptedState); + byte[] decrypted = cipher.doFinal(decoded); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt state", e); + } + } + + /** + * 상태 값을 검증. + * + * @param state 원본 상태 값 + * @param encryptedState 암호화된 상태 값 + * @return 검증 결과 (true: 유효, false: 무효) + */ + public boolean validateState(String state, String encryptedState) { + try { + String decryptedState = decryptState(encryptedState); + return decryptedState.equals(state); + } catch (Exception e) { + return false; + } + } + + /** + * 비밀 키를 생성. + * + * @param key 원본 비밀 키 + * @return AES 암호화에 사용할 키 + */ + private SecretKeySpec generateKey(String key) throws NoSuchAlgorithmException { + MessageDigest sha = MessageDigest.getInstance("SHA-256"); + byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); + keyBytes = sha.digest(keyBytes); + keyBytes = Arrays.copyOf(keyBytes, 16); // AES-128 키 길이 + return new SecretKeySpec(keyBytes, "AES"); + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/global/utils/DateUtils.java b/src/main/java/sevenstar/marineleisure/global/utils/DateUtils.java new file mode 100644 index 00000000..f841d156 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/utils/DateUtils.java @@ -0,0 +1,37 @@ +package sevenstar.marineleisure.global.utils; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +import lombok.experimental.UtilityClass; + +/** + * 날짜 관련 유틸리티 클래스입니다. + *

+ * 날짜를 특정 형식으로 포맷하거나, 날짜 범위를 생성하는 등의 기능을 제공합니다. + * @author gunwoong + */ +@UtilityClass +public class DateUtils { + private static final DateTimeFormatter REQ_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter FORECAST_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + + public static String formatTime(LocalDate localDate) { + return localDate.format(REQ_DATE_FORMATTER); + } + + /** + * 특정 날짜를 기준으로 date format 변경 + */ + public static LocalDate parseDate(String date) { + return LocalDate.parse(date, FORECAST_DATE_FORMATTER); + } + + public static String formatTime(LocalTime time) { + return time.format(DATE_TIME_FORMATTER); + + } + +} diff --git a/src/main/java/sevenstar/marineleisure/global/utils/FakeUtils.java b/src/main/java/sevenstar/marineleisure/global/utils/FakeUtils.java new file mode 100644 index 00000000..970aa8c4 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/utils/FakeUtils.java @@ -0,0 +1,96 @@ +package sevenstar.marineleisure.global.utils; + +import java.time.LocalDate; +import java.time.LocalTime; + +import lombok.experimental.UtilityClass; +import sevenstar.marineleisure.forecast.domain.Fishing; +import sevenstar.marineleisure.forecast.domain.FishingTarget; +import sevenstar.marineleisure.forecast.domain.Mudflat; +import sevenstar.marineleisure.forecast.domain.Scuba; +import sevenstar.marineleisure.forecast.domain.Surfing; +import sevenstar.marineleisure.global.enums.TidePhase; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; + +/** + * 외부 API 데이터를 수급하기 때문에 만약 수급 과정에서 누락이 발생할 경우 유연한 대처를 위한 fake 객체 리턴 + * @author gunwoong + */ +@UtilityClass +public class FakeUtils { + public static Fishing fakeFishing(Long spotId) { + return Fishing.builder() + .spotId(spotId) + .targetId(-1L) + .forecastDate(LocalDate.now()) + .timePeriod(TimePeriod.AM) + .tide(TidePhase.Intermediate_Tide) + .totalIndex(TotalIndex.NORMAL) + .waveHeightMin(0F) + .waveHeightMax(0F) + .seaTempMin(0F) + .seaTempMax(0F) + .airTempMin(0F) + .airTempMax(0F) + .currentSpeedMin(0F) + .currentSpeedMax(0F) + .windSpeedMin(0F) + .windSpeedMax(0F) + .uvIndex(0F) + .build(); + } + + public static FishingTarget fakeFishingTarget() { + return new FishingTarget(""); + } + + public static Surfing fakeSurfing(Long spotId) { + return Surfing.builder() + .spotId(spotId) + .forecastDate(LocalDate.now()) + .timePeriod(TimePeriod.AM) + .waveHeight(0F) + .wavePeriod(0F) + .windSpeed(0F) + .seaTemp(0F) + .totalIndex(TotalIndex.NORMAL) + .uvIndex(0F) + .build(); + } + + public static Scuba fakeScuba(Long spotId) { + return Scuba.builder() + .spotId(spotId) + .forecastDate(LocalDate.now()) + .timePeriod(TimePeriod.AM) + .tide(TidePhase.Intermediate_Tide) + .totalIndex(TotalIndex.NORMAL) + .waveHeightMin(0F) + .waveHeightMax(0F) + .seaTempMin(0F) + .seaTempMax(0F) + .currentSpeedMin(0F) + .currentSpeedMax(0F) + .sunrise(LocalTime.now()) + .sunset(LocalTime.now()) + .build(); + } + + public static Mudflat fakeMudflat(Long spotId) { + return Mudflat.builder() + .spotId(spotId) + .forecastDate(LocalDate.now()) + .startTime(LocalTime.now()) + .endTime(LocalTime.now()) + .airTempMin(0F) + .airTempMax(0F) + .windSpeedMin(0F) + .windSpeedMax(0F) + .weather("") + .totalIndex(TotalIndex.NORMAL) + .uvIndex(0F) + .build(); + + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/utils/GeoUtils.java b/src/main/java/sevenstar/marineleisure/global/utils/GeoUtils.java new file mode 100644 index 00000000..c485a5c3 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/utils/GeoUtils.java @@ -0,0 +1,20 @@ +package sevenstar.marineleisure.global.utils; + +import java.math.BigDecimal; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class GeoUtils { + private final GeometryFactory geometryFactory; + + public Point createPoint(BigDecimal latitude, BigDecimal longitude) { + return geometryFactory.createPoint(new Coordinate(longitude.doubleValue(), latitude.doubleValue())); + } +} diff --git a/src/main/java/sevenstar/marineleisure/global/utils/UriBuilder.java b/src/main/java/sevenstar/marineleisure/global/utils/UriBuilder.java new file mode 100644 index 00000000..a9961db5 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/global/utils/UriBuilder.java @@ -0,0 +1,25 @@ +package sevenstar.marineleisure.global.utils; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class UriBuilder { + public String encodeString(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + public URI buildQueryParameter(String baseUrl, String path, MultiValueMap params) { + return UriComponentsBuilder.fromUri(URI.create(baseUrl)).path(path).queryParams(params).build(true).toUri(); + } + + public URI buildQueryParameter(String baseUrl, MultiValueMap params) { + return UriComponentsBuilder.fromUri(URI.create(baseUrl)).queryParams(params).build(true).toUri(); + } +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/controller/MeetingController.java b/src/main/java/sevenstar/marineleisure/meeting/controller/MeetingController.java new file mode 100644 index 00000000..addb03d9 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/controller/MeetingController.java @@ -0,0 +1,165 @@ +package sevenstar.marineleisure.meeting.controller; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.global.domain.BaseResponse; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.jwt.UserPrincipal; +import sevenstar.marineleisure.meeting.dto.request.CreateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.request.UpdateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailAndMemberResponse; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailResponse; +import sevenstar.marineleisure.meeting.dto.response.MeetingListResponse; + +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; +import sevenstar.marineleisure.meeting.repository.TagRepository; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Tag; +import sevenstar.marineleisure.meeting.error.MeetingError; +import sevenstar.marineleisure.meeting.service.MeetingService; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.repository.MemberRepository; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class MeetingController { + private final MeetingService meetingService; + // N+1 문제를 발생시키기 위해 모든 관련 Repository를 주입받습니다. + private final MemberRepository memberRepository; + private final OutdoorSpotRepository outdoorSpotRepository; + private final TagRepository tagRepository; + private final ParticipantRepository participantRepository; + + @GetMapping("/meetings") + public ResponseEntity>> getAllListMeetings( + @RequestParam(name = "cursorId", defaultValue = "0") Long cursorId, + @RequestParam(name = "size", defaultValue = "10") Integer sizes + ) { + Slice not_mapping_result = meetingService.getAllMeetings(cursorId, sizes); + List dtoList = not_mapping_result.getContent().stream() + //TODO :: 개선예정 + .map(meeting -> { + Member host = memberRepository.findById(meeting.getHostId()) + .orElseThrow(() -> new RuntimeException("Host not found for meeting id: " + meeting.getId())); + OutdoorSpot spot = outdoorSpotRepository.findById(meeting.getSpotId()) + .orElseThrow(() -> new RuntimeException("Spot not found for meeting id: " + meeting.getId())); + Tag tag = tagRepository.findByMeetingId(meeting.getId()) + .orElseThrow(() -> new CustomException(MeetingError.MEETING_NOT_FOUND)); + long participantCount = participantRepository.countMeetingId(meeting.getId()) + .map(Integer::longValue) + .orElse(0L); + return MeetingListResponse.fromEntity(meeting, host, participantCount, spot, tag); + }) + .collect(Collectors.toList()); + Slice result = new SliceImpl<>(dtoList, not_mapping_result.getPageable(), not_mapping_result.hasNext()); + return BaseResponse.success(result); + } + @GetMapping("/meetings/{id}") + public ResponseEntity> getMeetingDetail( + @PathVariable("id") Long meetingId + ){ + return BaseResponse.success(meetingService.getMeetingDetails(meetingId)); + } + @GetMapping("/meetings/my") + public ResponseEntity>> getStatusListMeeting( + @RequestParam(name = "status",defaultValue = "RECRUITING") MeetingStatus status, + @RequestParam(name = "cursorId", defaultValue = "0") Long cursorId, + @RequestParam(name = "size", defaultValue = "10") Integer sizes, + @AuthenticationPrincipal UserPrincipal userDetails + ){ + + Long memberId = userDetails.getId(); + Slice not_mapping_result = meetingService.getStatusMyMeetings(memberId,cursorId,sizes,status); + List dtoList = not_mapping_result.getContent().stream() + //TODO :: 개선예정 + .map(meeting -> { + Member host = memberRepository.findById(meeting.getHostId()) + .orElseThrow(() -> new RuntimeException("Host not found for meeting id: " + meeting.getId())); + OutdoorSpot spot = outdoorSpotRepository.findById(meeting.getSpotId()) + .orElseThrow(() -> new RuntimeException("Spot not found for meeting id: " + meeting.getId())); + Tag tag = tagRepository.findByMeetingId(meeting.getId()) + .orElseThrow(() -> new CustomException(MeetingError.MEETING_NOT_FOUND)); + long participantCount = participantRepository.countMeetingId(meeting.getId()) + .map(Integer::longValue) + .orElse(0L); + return MeetingListResponse.fromEntity(meeting, host, participantCount, spot, tag); + }) + .collect(Collectors.toList()); + Slice result = new SliceImpl<>(dtoList, not_mapping_result.getPageable(), not_mapping_result.hasNext()); + return BaseResponse.success(result); + } + @GetMapping("/meetings/count") + public ResponseEntity> countMeetings(@AuthenticationPrincipal UserPrincipal userDetails){ + Long memberId = userDetails.getId(); + return BaseResponse.success(meetingService.countMeetings(memberId)); + } + @GetMapping("/meetings/{id}/members") + public ResponseEntity> getMeetingDetailAndMember( + @PathVariable("id") Long meetingId, + @AuthenticationPrincipal UserPrincipal userDetails + ){ + Long memberId = userDetails.getId(); + return BaseResponse.success(meetingService.getMeetingDetailAndMember(memberId,meetingId)); + } + @PostMapping("/meetings/{id}/join") + public ResponseEntity> joinMeeting( + @PathVariable("id") Long meetingId, + @AuthenticationPrincipal UserPrincipal userDetails + ){ + Long memberId = userDetails.getId(); + Long result = meetingService.joinMeeting(meetingId,memberId); + return BaseResponse.success(HttpStatus.CREATED, result); + } + @DeleteMapping("/meetings/{id}/leave") + public ResponseEntity> leaveMeeting( + @PathVariable("id") Long meetingId, + @AuthenticationPrincipal UserPrincipal userDetails + ){ + Long memberId = userDetails.getId(); + meetingService.leaveMeeting(meetingId,memberId); + return BaseResponse.success(HttpStatus.NO_CONTENT, "success") ; + } + + @PostMapping("/meetings") + public ResponseEntity> createMeeting( + @RequestBody CreateMeetingRequest request, + @AuthenticationPrincipal UserPrincipal userDetails + ){ + Long memberId = userDetails.getId(); + return BaseResponse.success(HttpStatus.CREATED,meetingService.createMeeting(memberId, request)); + } + + + @PutMapping("/meetings/{id}/update") + public ResponseEntity> updateMeeting( + @PathVariable("id") Long meetingId, + @RequestBody UpdateMeetingRequest request, + @AuthenticationPrincipal UserPrincipal userDetails + ){ + Long memberId = userDetails.getId(); + return BaseResponse.success(meetingService.updateMeeting(meetingId, memberId, request)); + } + + +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/meeting/domain/Meeting.java b/src/main/java/sevenstar/marineleisure/meeting/domain/Meeting.java new file mode 100644 index 00000000..2ad228f6 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/domain/Meeting.java @@ -0,0 +1,69 @@ +package sevenstar.marineleisure.meeting.domain; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.MeetingStatus; + +@Entity +@Getter +@Table(name = "meetings") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor +public class Meeting extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 20, nullable = false) + private String title; + + @Column(nullable = false) + private ActivityCategory category; + + @Column(nullable = false) + private int capacity; + + @Column(name = "host_id", nullable = false) + private Long hostId; + + @Column(name = "meeting_time", nullable = false) + private LocalDateTime meetingTime; + + @Column(nullable = false) + private MeetingStatus status; + + @Column(name = "spot_id", nullable = false) + private Long spotId; + + @Column(columnDefinition = "TEXT") + private String description; + + @Builder + public Meeting(LocalDateTime meetingTime, ActivityCategory category, int capacity, Long hostId, String title, + Long spotId, String description, MeetingStatus status) { + this.meetingTime = meetingTime; + this.category = category; + this.capacity = capacity; + this.hostId = hostId; + this.title = title; + this.spotId = spotId; + this.description = description; + this.status = status; + } + +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/domain/Participant.java b/src/main/java/sevenstar/marineleisure/meeting/domain/Participant.java new file mode 100644 index 00000000..821d8fb2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/domain/Participant.java @@ -0,0 +1,42 @@ +package sevenstar.marineleisure.meeting.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.MeetingRole; + +@Entity +@Getter +@Table(name = "meeting_participants") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Participant extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "meeting_id", nullable = false) + private Long meetingId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + + @Column(length = 20, nullable = false) + private MeetingRole role; + + @Builder + public Participant(Long meetingId, Long userId, MeetingRole role) { + this.meetingId = meetingId; + this.userId = userId; + this.role = role; + } +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/domain/Tag.java b/src/main/java/sevenstar/marineleisure/meeting/domain/Tag.java new file mode 100644 index 00000000..0839a117 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/domain/Tag.java @@ -0,0 +1,47 @@ +package sevenstar.marineleisure.meeting.domain; + +import java.util.List; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.meeting.service.util.StringListConverter; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "tags") +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class Tag extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "meeting_id", nullable = false) + private Long meetingId; + + + @Convert(converter = StringListConverter.class) + private List content; + + + @Builder + public Tag(Long meetingId, List content) { + this.meetingId = meetingId; + this.content = content; + } + +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/mapper/MeetingMapper.java b/src/main/java/sevenstar/marineleisure/meeting/dto/mapper/MeetingMapper.java new file mode 100644 index 00000000..18c8c313 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/mapper/MeetingMapper.java @@ -0,0 +1,169 @@ +package sevenstar.marineleisure.meeting.dto.mapper; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import sevenstar.marineleisure.global.enums.MeetingRole; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.meeting.domain.Participant; +import sevenstar.marineleisure.meeting.dto.request.CreateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.request.UpdateMeetingRequest; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Tag; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailAndMemberResponse; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailResponse; +import sevenstar.marineleisure.meeting.dto.response.ParticipantResponse; +import sevenstar.marineleisure.meeting.dto.vo.DetailSpot; +import sevenstar.marineleisure.meeting.dto.vo.TagList; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; + +@Component +public class MeetingMapper { + public Meeting UpdateStatus(Meeting meeting, MeetingStatus status) { + return Meeting.builder() + .id(meeting.getId()) + .title(meeting.getTitle()) + .category(meeting.getCategory()) + .capacity(meeting.getCapacity()) + .hostId(meeting.getHostId()) + .meetingTime(meeting.getMeetingTime()) + .status(status) + .spotId(meeting.getSpotId()) + .description(meeting.getDescription()) + .build(); + } + + public Meeting CreateMeeting(CreateMeetingRequest request, Long hostId) { + return Meeting.builder() + .title(request.title()) + .category(request.category()) + .capacity(request.capacity()) + .hostId(hostId) + .meetingTime(request.meetingTime()) + .status(MeetingStatus.RECRUITING) + .spotId(request.spotId()) + .description(request.description()) + .build(); + } + + public Meeting UpdateMeeting(UpdateMeetingRequest request, Meeting meeting) { + return + Meeting.builder() + .id(meeting.getId()) + .title(request.title() != null ? request.title() : meeting.getTitle()) + .category(request.category() != null ? request.category() : meeting.getCategory()) + .capacity(request.capacity() != null ? request.capacity() : meeting.getCapacity()) + .hostId(meeting.getHostId()) + .meetingTime(request.localDateTime() != null ? request.localDateTime() : meeting.getMeetingTime()) + .status(meeting.getStatus()) + .spotId(request.spotId() != null ? request.spotId() : meeting.getSpotId()) + .description(request.description() != null ? request.description() : meeting.getDescription()) + .build(); + + } + + public Tag UpdateTag(UpdateMeetingRequest request, Tag tag) { + return + //Tag 매퍼를 써야함 + Tag.builder() + .id(tag.getId()) + .meetingId(tag.getMeetingId()) + .content(request.tag().content() != null ? request.tag().content() : tag.getContent()) + .build(); + } + + public MeetingDetailResponse MeetingDetailResponseMapper(Meeting targetMeeting, Member host, + OutdoorSpot targetSpot, Tag targetTag) { + return MeetingDetailResponse.builder() + .id(targetMeeting.getId()) + .title(targetMeeting.getTitle()) + .category(targetMeeting.getCategory()) + .capacity(targetMeeting.getCapacity()) + .hostId(targetMeeting.getHostId()) + .hostNickName(host.getNickname()) + .hostEmail(host.getEmail()) + .description(targetMeeting.getDescription()) + .spot(DetailSpot.builder() + .id(targetSpot.getId()) + .name(targetSpot.getName()) + .location(targetSpot.getLocation()) + .build()) + .meetingTime(targetMeeting.getMeetingTime()) + .status(targetMeeting.getStatus()) + .createdAt(targetMeeting.getCreatedAt()) + .tag(TagList.builder() + .content(targetTag.getContent()) + .build()) + .build(); + } + + public MeetingDetailAndMemberResponse meetingDetailAndMemberResponseMapper + (Meeting targetMeeting, Member host, OutdoorSpot targetSpot, + List participantResponseList + , Tag tag) { + return MeetingDetailAndMemberResponse.builder() + .id(targetMeeting.getId()) + .title(targetMeeting.getTitle()) + .category(targetMeeting.getCategory()) + .capacity(targetMeeting.getCapacity()) + .hostId(targetMeeting.getHostId()) + .hostNickName(host.getNickname()) + .spot( + DetailSpot.builder() + .id(targetMeeting.getSpotId()) + .name(targetSpot.getName()) + .location(targetSpot.getLocation()) + .latitude(targetSpot.getLatitude()) + .longitude(targetSpot.getLongitude()) + .build() + ) + .meetingTime(targetMeeting.getMeetingTime()) + .status(targetMeeting.getStatus()) + .participants( + participantResponseList + ) + .createdAt(targetMeeting.getCreatedAt()) + .tagList(DetailTag(tag)) + .build(); + } + + public List toParticipantResponseList(List participants, + Map participantNicknames) { + if (participants == null || participants.isEmpty()) { + return Collections.emptyList(); + } + return participants.stream() + .map(participant -> ParticipantResponse.builder() + .id(participant.getUserId()) + .role(participant.getRole()) + .nickName(participantNicknames.get(participant.getUserId())) + .build()) + .toList(); + + } + + public Participant saveParticipant(Long memberId,Long meetingId,MeetingRole role){ + return Participant.builder() + .meetingId(meetingId) + .userId(memberId) + .role(role) + .build(); + } + + public Tag saveTag(Long meetingId, CreateMeetingRequest request){ + return Tag.builder() + .meetingId(meetingId) + .content(request.tags()) + .build(); + } + + public TagList DetailTag(Tag tag){ + return TagList.builder() + .content(tag.getContent()) + .build(); + } +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/request/CreateMeetingRequest.java b/src/main/java/sevenstar/marineleisure/meeting/dto/request/CreateMeetingRequest.java new file mode 100644 index 00000000..66b2da3e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/request/CreateMeetingRequest.java @@ -0,0 +1,30 @@ +package sevenstar.marineleisure.meeting.dto.request; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.ActivityCategory; + + +/** + * + * @param category : FISHING , SURFING , DIVING , MUDFLAT + * @param capacity : 총 인원 + * @param title : Meeting 의 이름 + * @param meetingTime : 모임 시간 + * @param spotId : 장소 Id + * @param description : 모임 설명 + * @param tags : 모임 태그 -> 요청 받을떄는 tags로 받고 VO는 서비스 안에서 변환해야할 것 같습니다. + */ +@Builder +public record CreateMeetingRequest( + ActivityCategory category, + Integer capacity, + String title, + LocalDateTime meetingTime, + Long spotId, + String description, + List tags +) { +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/request/UpdateMeetingRequest.java b/src/main/java/sevenstar/marineleisure/meeting/dto/request/UpdateMeetingRequest.java new file mode 100644 index 00000000..292e4ca2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/request/UpdateMeetingRequest.java @@ -0,0 +1,20 @@ +package sevenstar.marineleisure.meeting.dto.request; + +import java.time.LocalDateTime; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.meeting.dto.vo.TagList; + +@Builder +public record UpdateMeetingRequest( + String title, + ActivityCategory category, + Integer capacity, + LocalDateTime localDateTime, + Long spotId, + String description, + TagList tag +) + { +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/response/MeetingDetailAndMemberResponse.java b/src/main/java/sevenstar/marineleisure/meeting/dto/response/MeetingDetailAndMemberResponse.java new file mode 100644 index 00000000..35777292 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/response/MeetingDetailAndMemberResponse.java @@ -0,0 +1,45 @@ +package sevenstar.marineleisure.meeting.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.meeting.dto.vo.DetailSpot; +import sevenstar.marineleisure.meeting.dto.vo.TagList; + +/** + * + * @param id : meeting id + * @param title : meeting + * @param category : FISHING, SURFING , DIVING , MUDFLAT + * @param capacity : 총인원수 + * @param hostId : 모임하는 사람의 ID + * @param hostNickName : 모임하는 사람의 NickName + * @param hostEmail : 모임하는 사람의 EMAIL + * @param description : 모임의 설명 + * @param spot : SPOT객체를 줍니다. (값객체로 Response할 예정) + * @param meetingTime : 모임 시간 설정 + * @param status : 모임의 상태 : RECRUITING , ONGOING , FULL , COMPLETED + * @param participants : 참여한 인원의 수 ( 값객체로 변환 ) + * @param createdAt : 생성시간 + */ +@Builder +public record MeetingDetailAndMemberResponse( + long id, + String title, + ActivityCategory category, + long capacity, + long hostId, + String hostNickName, + String hostEmail, + String description, + DetailSpot spot, + LocalDateTime meetingTime, + MeetingStatus status, + List participants, + TagList tagList, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/response/MeetingDetailResponse.java b/src/main/java/sevenstar/marineleisure/meeting/dto/response/MeetingDetailResponse.java new file mode 100644 index 00000000..d16c46d4 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/response/MeetingDetailResponse.java @@ -0,0 +1,43 @@ +package sevenstar.marineleisure.meeting.dto.response; + +import java.time.LocalDateTime; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.meeting.dto.vo.DetailSpot; +import sevenstar.marineleisure.meeting.dto.vo.TagList; + +/** + * + * @param id : meetingID 반환 + * @param title : meeting의 제목 + * @param category : FISHING , SURFING , DIVING , MUDFLAT + * @param capacity : 총인원수 + * @param hostId : 모임장의 ID + * @param hostNickName : 모임장의 닉네임 + * @param hostEmail : 모임장의 EMAIL + * @param description : 모임의 설명 + * @param spot : 장소의 객체 + * @param meetingTime : 모임 예정시간 + * @param status : 상태 MeetingStatus.java 참고 + * @param createdAt : 만들어진 시간 + */ +@Builder +public record MeetingDetailResponse( + long id, + String title, + ActivityCategory category, + long capacity, + long hostId, + String hostNickName, + String hostEmail, + String description, + DetailSpot spot, + LocalDateTime meetingTime, + MeetingStatus status, + LocalDateTime createdAt, + TagList tag +) { + +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/response/MeetingListResponse.java b/src/main/java/sevenstar/marineleisure/meeting/dto/response/MeetingListResponse.java new file mode 100644 index 00000000..8a0884dd --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/response/MeetingListResponse.java @@ -0,0 +1,56 @@ +package sevenstar.marineleisure.meeting.dto.response; + +import java.time.LocalDateTime; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.meeting.dto.vo.ListSpot; +import sevenstar.marineleisure.meeting.dto.vo.TagList; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Tag; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; + +@Builder +public record MeetingListResponse( + long id, + String title, + ActivityCategory category, + Integer capacity, + long currentParticipants, + long hostId, + String hostNickName, + LocalDateTime meetingTime, + MeetingStatus status, + ListSpot spot, + TagList tag +) { + public static MeetingListResponse fromEntity(Meeting meeting , Member host, Long participantCount, OutdoorSpot spot, + Tag tag){ + return MeetingListResponse.builder() + .id(meeting.getId()) + .title(meeting.getTitle()) + .category(meeting.getCategory()) + .capacity(meeting.getCapacity()) + .currentParticipants(participantCount) + .hostId(meeting.getHostId()) + .hostNickName(host.getNickname()) + .meetingTime(meeting.getMeetingTime()) + .status(meeting.getStatus()) + .spot(ListSpot.builder() + .id(spot.getId()) + .name(spot.getName()) + .location(spot.getLocation()) + .build()) + .tag(TagList.builder() + .content( + tag.getContent() + ) + .build() + ) + .build(); + + } + +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/response/ParticipantResponse.java b/src/main/java/sevenstar/marineleisure/meeting/dto/response/ParticipantResponse.java new file mode 100644 index 00000000..f6beebdb --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/response/ParticipantResponse.java @@ -0,0 +1,12 @@ +package sevenstar.marineleisure.meeting.dto.response; + +import lombok.Builder; +import sevenstar.marineleisure.global.enums.MeetingRole; + +@Builder +public record ParticipantResponse( + long id, + MeetingRole role, + String nickName +) { +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/vo/DetailSpot.java b/src/main/java/sevenstar/marineleisure/meeting/dto/vo/DetailSpot.java new file mode 100644 index 00000000..759c2080 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/vo/DetailSpot.java @@ -0,0 +1,16 @@ +package sevenstar.marineleisure.meeting.dto.vo; + +import java.math.BigDecimal; + +import lombok.Builder; + +@Builder +public record DetailSpot( + long id, + String name, + String location, + BigDecimal latitude, + BigDecimal longitude +) { + +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/vo/ListSpot.java b/src/main/java/sevenstar/marineleisure/meeting/dto/vo/ListSpot.java new file mode 100644 index 00000000..128382a4 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/vo/ListSpot.java @@ -0,0 +1,12 @@ +package sevenstar.marineleisure.meeting.dto.vo; + +import lombok.Builder; + +@Builder +public record ListSpot( + long id, + String name, + String location +) { + +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/dto/vo/TagList.java b/src/main/java/sevenstar/marineleisure/meeting/dto/vo/TagList.java new file mode 100644 index 00000000..0791a473 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/dto/vo/TagList.java @@ -0,0 +1,12 @@ +package sevenstar.marineleisure.meeting.dto.vo; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record TagList( + List content +) { + +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/error/MeetingError.java b/src/main/java/sevenstar/marineleisure/meeting/error/MeetingError.java new file mode 100644 index 00000000..62594ab7 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/error/MeetingError.java @@ -0,0 +1,43 @@ +package sevenstar.marineleisure.meeting.error; + +import org.springframework.http.HttpStatus; +import sevenstar.marineleisure.global.exception.enums.ErrorCode; + +public enum MeetingError implements ErrorCode { + //2XXX에러 + MEETING_NOT_FOUND(2404, HttpStatus.NOT_FOUND, "Meeting Not Found"), + MEETING_ALREADY_FULL(2409, HttpStatus.CONFLICT, "Meeting is Full"), + MEETING_NOT_RECRUITING(2400,HttpStatus.BAD_REQUEST,"Not Recruiting"), + MEETING_NOT_HOST(2400,HttpStatus.BAD_REQUEST,"Not Host"), + MEETING_NOT_LEAVE_HOST(2409,HttpStatus.CONFLICT ,"Not LeaveHost" ), + CANNOT_LEAVE_COMPLETED_MEETING(2400,HttpStatus.BAD_REQUEST,"Cannot Leave"), + ; + + + private final int code; + private final HttpStatus httpStatus; + private final String message; + + + MeetingError(int code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } + + + @Override + public int getCode() { + return code; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/meeting/error/MemberError.java b/src/main/java/sevenstar/marineleisure/meeting/error/MemberError.java new file mode 100644 index 00000000..8b49b660 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/error/MemberError.java @@ -0,0 +1,37 @@ +package sevenstar.marineleisure.meeting.error; + +import org.springframework.http.HttpStatus; + +import sevenstar.marineleisure.global.exception.enums.ErrorCode; + +//1XXX 에러 +public enum MemberError implements ErrorCode { + + MEMBER_NOT_FOUND(1404, HttpStatus.NOT_FOUND, "Member not found"), + MEMBER_NOT_EXIST(1404, HttpStatus.NOT_FOUND, "Member not exist"),; + + private final int code; + private final HttpStatus httpStatus; + private final String message; + + MemberError(int code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public int getCode() { + return code; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/meeting/error/ParticipantError.java b/src/main/java/sevenstar/marineleisure/meeting/error/ParticipantError.java new file mode 100644 index 00000000..5edea17f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/error/ParticipantError.java @@ -0,0 +1,37 @@ +package sevenstar.marineleisure.meeting.error; + +import org.springframework.http.HttpStatus; + +import sevenstar.marineleisure.global.exception.enums.ErrorCode; + +public enum ParticipantError implements ErrorCode { + PARTICIPANT_NOT_FOUND(2404, HttpStatus.NOT_FOUND, "Participant not found"), + PARTICIPANT_NOT_EXIST(2404, HttpStatus.NOT_FOUND, "Participant not exist"), + PARTICIPANT_ERROR_COUNT(2409,HttpStatus.CONFLICT, "Participant error count"), + ALREADY_PARTICIPATING(2409,HttpStatus.CONFLICT, "Alrealdy participating"),; + + private final int code; + private final HttpStatus httpStatus; + private final String message; + + ParticipantError(int code , HttpStatus httpStatus, String meesage){ + this.code = code; + this.httpStatus = httpStatus; + this.message = meesage; + } + + @Override + public int getCode() { + return code; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/error/SpotError.java b/src/main/java/sevenstar/marineleisure/meeting/error/SpotError.java new file mode 100644 index 00000000..7246156b --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/error/SpotError.java @@ -0,0 +1,36 @@ +package sevenstar.marineleisure.meeting.error; + +import org.springframework.http.HttpStatus; + +import sevenstar.marineleisure.global.exception.enums.ErrorCode; + +//3xxx +public enum SpotError implements ErrorCode { + SPOT_NOT_FOUND(3404, HttpStatus.NOT_FOUND, "Spot not found"); + + private final int code; + private final HttpStatus httpStatus; + private final String message; + + SpotError(int code, HttpStatus httpStatus, String message) { + this.code = code; + this.httpStatus = httpStatus; + this.message = message; + } + + + @Override + public int getCode() { + return code; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/repository/MeetingRepository.java b/src/main/java/sevenstar/marineleisure/meeting/repository/MeetingRepository.java new file mode 100644 index 00000000..47c9f4d9 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/repository/MeetingRepository.java @@ -0,0 +1,36 @@ +package sevenstar.marineleisure.meeting.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.meeting.domain.Meeting; + +@Repository +public interface MeetingRepository extends JpaRepository { + + @Query( + "SELECT m FROM Meeting m ORDER BY m.createdAt DESC, m.id DESC" + ) + Slice findAllByOrderByCreatedAtDescIdDesc(Pageable pageable); + + @Query("SELECT m FROM Meeting m WHERE (m.createdAt < :createdAt OR (m.createdAt = :createdAt AND m.id < :meetingId)) ORDER BY m.createdAt DESC, m.id DESC") + Slice findAllOrderByCreatedAt(@Param("createdAt") LocalDateTime createdAt, @Param("meetingId") Long meetingId, Pageable pageable); + + @Query("SELECT m FROM Meeting m WHERE m.hostId = :memberId AND m.status = :status AND m.id < :cursorId ORDER BY m.id DESC") + Slice findMyMeetingsByMemberIdAndStatusWithCursor(@Param("memberId") Long memberId, @Param("status") MeetingStatus status, @Param("cursorId") Long cursorId, Pageable pageable); + + @Query("SELECT COUNT(m) FROM Meeting m WHERE m.hostId = :memberId") + Long countMyMeetingsByMemberId(@Param("memberId") Long memberId); + + @Query("SELECT m FROM Meeting m WHERE m.hostId = :memberId") + List findByHostId(@Param("memberId") Long memberId); + +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/repository/ParticipantRepository.java b/src/main/java/sevenstar/marineleisure/meeting/repository/ParticipantRepository.java new file mode 100644 index 00000000..206cf3aa --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/repository/ParticipantRepository.java @@ -0,0 +1,29 @@ +package sevenstar.marineleisure.meeting.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import sevenstar.marineleisure.meeting.domain.Participant; + +@Repository +public interface ParticipantRepository extends JpaRepository { + + @Query("SELECT count(*) FROM Participant p WHERE p.meetingId = :meetingId") + Optional countMeetingId(@Param("meetingId") Long meetingId); + + Optional findByMeetingIdAndUserId(Long meetingId, Long userId); + + @Query("SELECT p FROM Participant p WHERE p.meetingId = :meetingId") + List findParticipantsByMeetingId(@Param("meetingId") Long meetingId); + + boolean existsByUserId(Long userId); + + boolean existsByMeetingIdAndUserId(Long meetingId, Long memberId); + + List findByUserId(Long memberId); +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/repository/TagRepository.java b/src/main/java/sevenstar/marineleisure/meeting/repository/TagRepository.java new file mode 100644 index 00000000..1ff286b8 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/repository/TagRepository.java @@ -0,0 +1,18 @@ +package sevenstar.marineleisure.meeting.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import sevenstar.marineleisure.meeting.domain.Tag; + +@Repository +public interface TagRepository extends JpaRepository { + Optional findByMeetingId(Long meetingId); + + @Query("SELECT t.content FROM Tag t WHERE t.meetingId = :meetingId") + List findContentsByMeetingId(Long meetingId); +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/service/MeetingService.java b/src/main/java/sevenstar/marineleisure/meeting/service/MeetingService.java new file mode 100644 index 00000000..05810c7b --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/service/MeetingService.java @@ -0,0 +1,99 @@ +package sevenstar.marineleisure.meeting.service; + +import org.springframework.data.domain.Slice; + +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.meeting.dto.request.CreateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.request.UpdateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailAndMemberResponse; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailResponse; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.member.domain.Member; + +/** + * member 은 공통적으로 CustomMemberDetail 에서 가져온 memberDetail로 변경 예정입니다. + */ +public interface MeetingService { + + /** + * 모임 목록 조회 + * [GET] /meetings + * @param cursorId : cursorId 부터 탐색 합니다. + * @param size : 가져올 갯수 + * @return + */ + Slice getAllMeetings(Long cursorId, int size); + + /** + * 모임 상세 정보 조회 + * [GET] /meetings/{id} + * @param meetingId : meeting.Id를 받아옵니다. + * @return + */ + MeetingDetailResponse getMeetingDetails(Long meetingId); + + /** + * + * @param memberId + * @param cursorId + * @param size + * @param MeetingStatus + * @return + */ + Slice getStatusMyMeetings(Long memberId,Long cursorId, int size , MeetingStatus MeetingStatus); + + + MeetingDetailAndMemberResponse getMeetingDetailAndMember(Long memberId, Long meetingId); + + /** + * 모임 개수 조회 - 대시보드용 + * [GET] /meeting/counts + * @param memberId + * @return Count 형식이라서 Long 형태로 넘겨받았습니다. + */ + Long countMeetings(Long memberId); + + /** + * 모임참여 + * [POST] /meeting/{id} + * @param meetingId : 현재 참여하는 Id를 줍니다. + * @param memberId + * @return meetingId -> 참여한 meetingId 로 넘겨줍니다. + */ + Long joinMeeting(Long meetingId, Long memberId); + + /** + * 모임 참여 취소 + * [DELETE] /meetings/{id} + * @param meetingId : MeetingId + * @param memberId + */ + void leaveMeeting(Long meetingId,Long memberId); + + /** + * 모임 생성 + * [POST] /meetings + * @param memberId + * @param request : CreateMeetingRequest : VO로 tags를 받지 않기때문에 서비스 로직에서 tags를 따로 DTO에 넣어줘야합니다. + * @return Long 형태로 MeetingId를 반환할 것 같습니다. + */ + Long createMeeting(Long memberId, CreateMeetingRequest request); + + /** + * 모임 정보 수정 + * [PUT] /meetings/{id} + * @param meetingId : memberId + * @param memberId + * @param request : + * @return + */ + Long updateMeeting(Long meetingId, Long memberId, UpdateMeetingRequest request); + + /** + * 모임 해체 + * [DELETE] /meetings/{id} + * @param member + * @param meetingId + */ + void deleteMeeting(Member member, Long meetingId); +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/service/MeetingServiceImpl.java b/src/main/java/sevenstar/marineleisure/meeting/service/MeetingServiceImpl.java new file mode 100644 index 00000000..65cc2d8e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/service/MeetingServiceImpl.java @@ -0,0 +1,188 @@ +package sevenstar.marineleisure.meeting.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.global.enums.MeetingRole; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Participant; +import sevenstar.marineleisure.meeting.domain.Tag; +import sevenstar.marineleisure.meeting.dto.mapper.MeetingMapper; +import sevenstar.marineleisure.meeting.dto.request.CreateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.request.UpdateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailAndMemberResponse; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailResponse; +import sevenstar.marineleisure.meeting.dto.response.ParticipantResponse; +import sevenstar.marineleisure.meeting.dto.mapper.MeetingMapper; +import sevenstar.marineleisure.meeting.repository.MeetingRepository; +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; +import sevenstar.marineleisure.meeting.repository.TagRepository; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Participant; +import sevenstar.marineleisure.meeting.domain.Tag; +import sevenstar.marineleisure.meeting.validate.MeetingValidate; +import sevenstar.marineleisure.meeting.validate.MemberValidate; +import sevenstar.marineleisure.meeting.validate.ParticipantValidate; +import sevenstar.marineleisure.meeting.validate.SpotValidate; +import sevenstar.marineleisure.meeting.validate.TagValidate; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.repository.MemberRepository; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@Service +@RequiredArgsConstructor +public class MeetingServiceImpl implements MeetingService { + private final MeetingRepository meetingRepository; + private final ParticipantRepository participantRepository; + private final TagRepository tagRepository; + private final MemberRepository memberRepository; + private final OutdoorSpotRepository outdoorSpotSpotRepository; + private final ParticipantValidate participantValidate; + private final MeetingMapper meetingMapper; + private final MeetingValidate meetingValidate; + private final MemberValidate memberValidate; + private final TagValidate tagValidate; + private final SpotValidate spotValidate; + + @Override + @Transactional(readOnly = true) + //TODO : 카테고리 별로 확인 하는 방법 고민하기? + public Slice getAllMeetings(Long cursorId, int size) { + Pageable pageable = PageRequest.of(0, size); + if (cursorId == 0L) { + return meetingRepository.findAllByOrderByCreatedAtDescIdDesc(pageable); + } else { + Meeting meeting = meetingValidate.foundMeeting(cursorId); + return meetingRepository.findAllOrderByCreatedAt(meeting.getCreatedAt(), meeting.getId(), pageable); + } + } + + @Override + @Transactional(readOnly = true) + public MeetingDetailResponse getMeetingDetails(Long meetingId) { + //TODO : select 세번 해야하는것에 대한 개선점 찾기 -> JOIN 패치를 진행 하기로 맘을 먹었음 + //TODO : 그럼에도 JPA 매핑과 JOIN에 대한 속도차이같은걸 조금 알면 좋을듯? + Meeting targetMeeting = meetingValidate.foundMeeting(meetingId); + Member host = memberValidate.foundMember(targetMeeting.getHostId()); + OutdoorSpot targetSpot = spotValidate.foundOutdoorSpot(targetMeeting.getSpotId()); + Tag targetTag = tagValidate.findByMeetingId(meetingId).orElse(null); + + return meetingMapper.MeetingDetailResponseMapper(targetMeeting, host, targetSpot, targetTag); + } + + @Override + @Transactional(readOnly = true) + public Slice getStatusMyMeetings(Long memberId, Long cursorId, int size, MeetingStatus meetingStatus) { + Pageable pageable = PageRequest.of(0, size); + memberValidate.existMember(memberId); + Long currentCursorId = (cursorId == null || cursorId == 0L) ? Long.MAX_VALUE : cursorId; + return meetingRepository.findMyMeetingsByMemberIdAndStatusWithCursor(memberId, meetingStatus, + currentCursorId, pageable); + } + + @Override + @Transactional(readOnly = true) + public MeetingDetailAndMemberResponse getMeetingDetailAndMember(Long memberId , Long meetingId){ + Member host = memberValidate.foundMember(memberId); + Meeting targetMeeting = meetingValidate.foundMeeting(meetingId); + meetingValidate.verifyIsHost(host.getId(), meetingId); + OutdoorSpot targetSpot = spotValidate.foundOutdoorSpot(targetMeeting.getSpotId()); + List participants = participantRepository.findParticipantsByMeetingId(meetingId); + participantValidate.existParticipant(memberId); + List participantUserIds = participants.stream() + .map(Participant::getUserId) + .toList(); + Map participantNicknames = memberRepository.findAllById(participantUserIds).stream() + .collect(Collectors.toMap(Member::getId, Member::getNickname)); + Tag targetTag = tagValidate.findByMeetingId(meetingId).orElse(null); + List participantResponseList = meetingMapper.toParticipantResponseList(participants,participantNicknames); + return meetingMapper.meetingDetailAndMemberResponseMapper(targetMeeting,host,targetSpot,participantResponseList,targetTag); + } + + @Override + @Transactional(readOnly = true) + public Long countMeetings(Long memberId) { + memberValidate.existMember(memberId); + return meetingRepository.countMyMeetingsByMemberId(memberId); + } + + @Override + @Transactional + //동시성을 처리해야할 문제가 있음 + public Long joinMeeting(Long meetingId, Long memberId) { + memberValidate.existMember(memberId); + Meeting meeting = meetingValidate.foundMeeting(meetingId); + meetingValidate.verifyRecruiting(meeting); + participantValidate.verifyNotAlreadyParticipant(memberId, meetingId); + int targetCount = participantValidate.getParticipantCount(meetingId); + meetingValidate.verifyMeetingCount(targetCount,meeting); + participantRepository.save( + meetingMapper.saveParticipant(memberId , meetingId , MeetingRole.GUEST) + ); + return meetingId; + } + + @Override + @Transactional + public void leaveMeeting(Long meetingId, Long memberId) { + memberValidate.existMember(memberId); + Meeting meeting = meetingValidate.foundMeeting(meetingId); + participantValidate.existParticipant(memberId); + meetingValidate.verifyNotHost(memberId,meeting); + meetingValidate.verifyLeave(meeting); + Participant targetParticipant = participantValidate.foundParticipantMeetingIdAndUserId(meetingId, memberId); + participantRepository.delete(targetParticipant); + if (meeting.getStatus() == MeetingStatus.FULL) { + meetingRepository.save(meetingMapper.UpdateStatus(meeting, MeetingStatus.RECRUITING)); + } + + } + + @Override + @Transactional + public Long createMeeting(Long memberId, CreateMeetingRequest request) { + Member host = memberValidate.foundMember(memberId); + Meeting saveMeeting = meetingRepository.save(meetingMapper.CreateMeeting(request, host.getId())); + participantRepository.save( + meetingMapper.saveParticipant(saveMeeting.getId(),host.getId(),MeetingRole.HOST) + ); + tagRepository.save( + meetingMapper.saveTag(saveMeeting.getId(), request) + ); + + return saveMeeting.getId(); + } + + //어떻게 해야할지 고민을 해야할 것 같습니다. + @Override + @Transactional + public Long updateMeeting(Long meetingId, Long memberId, UpdateMeetingRequest request) { + Member host = memberValidate.foundMember(memberId); + Meeting targetMeeting = meetingValidate.foundMeeting(meetingId); + meetingValidate.verifyIsHost(host.getId(), targetMeeting.getHostId()); + Tag targetTag = tagValidate.findByMeetingId(meetingId).orElse(null); + Meeting updateMeeting = meetingRepository.save(meetingMapper.UpdateMeeting(request, targetMeeting)); + tagRepository.save( + meetingMapper.UpdateTag(request, targetTag) + ); + return updateMeeting.getId(); + + } + // 프론트분한테 물어보기 대작전 해야할듯 + //삭제 할 필요가 있을까? 고민해봐야할것같음. + @Override + public void deleteMeeting(Member member, Long meetingId) { + + } +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/service/util/StringListConverter.java b/src/main/java/sevenstar/marineleisure/meeting/service/util/StringListConverter.java new file mode 100644 index 00000000..dc69dcdc --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/service/util/StringListConverter.java @@ -0,0 +1,33 @@ +package sevenstar.marineleisure.meeting.service.util; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class StringListConverter implements AttributeConverter, String> { + + private static final String SPLIT_CHAR = ","; + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) { + return null; + } + return attribute.stream().map(String::trim).collect(Collectors.joining(SPLIT_CHAR)); + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.trim().isEmpty()) { + return Collections.emptyList(); + } + return Arrays.stream(dbData.split(SPLIT_CHAR)) + .map(String::trim) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/validate/MeetingValidate.java b/src/main/java/sevenstar/marineleisure/meeting/validate/MeetingValidate.java new file mode 100644 index 00000000..b9092a04 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/validate/MeetingValidate.java @@ -0,0 +1,63 @@ +package sevenstar.marineleisure.meeting.validate; + +import java.util.Objects; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.meeting.repository.MeetingRepository; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.error.MeetingError; + +@Component +@RequiredArgsConstructor +public class MeetingValidate { + + private final MeetingRepository meetingRepository; + + @Transactional(readOnly = true) + public Meeting foundMeeting(Long meetingId){ + return meetingRepository.findById(meetingId) + .orElseThrow(() -> new CustomException(MeetingError.MEETING_NOT_FOUND)); + + } + + @Transactional(readOnly = true) + public void verifyIsHost(Long memberId, Long hostId){ + if(!Objects.equals(hostId, memberId)){ + throw new CustomException(MeetingError.MEETING_NOT_HOST); + } + } + + @Transactional(readOnly = true) + public void verifyRecruiting(Meeting meeting){ + if(meeting.getStatus() != MeetingStatus.RECRUITING){ + throw new CustomException(MeetingError.MEETING_NOT_RECRUITING); + } + } + + @Transactional(readOnly = true) + public void verifyMeetingCount(int targetCount, Meeting meeting){ + if(targetCount >= meeting.getCapacity()){ + throw new CustomException(MeetingError.MEETING_ALREADY_FULL); + } + } + + @Transactional(readOnly = true) + public void verifyNotHost(Long memberId, Meeting meeting){ + if(memberId.equals(meeting.getHostId())){ + throw new CustomException(MeetingError.MEETING_NOT_LEAVE_HOST); + } + } + + @Transactional(readOnly = true) + public void verifyLeave(Meeting meeting){ + if(meeting.getStatus() == MeetingStatus.COMPLETED || meeting.getStatus() == MeetingStatus.ONGOING){ + throw new CustomException(MeetingError.CANNOT_LEAVE_COMPLETED_MEETING); + } + } + +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/meeting/validate/MemberValidate.java b/src/main/java/sevenstar/marineleisure/meeting/validate/MemberValidate.java new file mode 100644 index 00000000..312d3ab8 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/validate/MemberValidate.java @@ -0,0 +1,31 @@ +package sevenstar.marineleisure.meeting.validate; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.global.exception.CustomException; + +import sevenstar.marineleisure.meeting.error.MemberError; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.repository.MemberRepository; + +@Component +@RequiredArgsConstructor +public class MemberValidate { + + private final MemberRepository memberRepository; + + @Transactional(readOnly = true) + public Member foundMember(Long memberId){ + return memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(MemberError.MEMBER_NOT_FOUND)); + } + + @Transactional(readOnly = true) + public void existMember(Long memberId){ + if(!memberRepository.existsById(memberId)){ + throw new CustomException(MemberError.MEMBER_NOT_EXIST); + } + } +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/validate/ParticipantValidate.java b/src/main/java/sevenstar/marineleisure/meeting/validate/ParticipantValidate.java new file mode 100644 index 00000000..3e662ca5 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/validate/ParticipantValidate.java @@ -0,0 +1,43 @@ +package sevenstar.marineleisure.meeting.validate; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.meeting.domain.Participant; +import sevenstar.marineleisure.meeting.error.ParticipantError; +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; + +@Component +@RequiredArgsConstructor +public class ParticipantValidate { + private final ParticipantRepository participantRepository; + + @Transactional(readOnly = true) + public void existParticipant(Long memberId){ + if(!participantRepository.existsByUserId(memberId)){ + throw new CustomException(ParticipantError.PARTICIPANT_NOT_EXIST); + } + } + + @Transactional(readOnly = true) + public Participant foundParticipantMeetingIdAndUserId(Long meetingId , Long memberId){ + return participantRepository.findByMeetingIdAndUserId(meetingId, memberId) + .orElseThrow(() -> new CustomException(ParticipantError.PARTICIPANT_NOT_FOUND)); + } + + @Transactional(readOnly = true) + public int getParticipantCount(Long meetingId){ + return participantRepository.countMeetingId(meetingId) + .orElseThrow(() -> new CustomException(ParticipantError.PARTICIPANT_ERROR_COUNT)); + } + + @Transactional(readOnly = true) + public void verifyNotAlreadyParticipant(Long meetingId, Long memberId){ + if(participantRepository.existsByMeetingIdAndUserId(meetingId, memberId)){ + throw new CustomException(ParticipantError.ALREADY_PARTICIPATING); + } + } + +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/validate/SpotValidate.java b/src/main/java/sevenstar/marineleisure/meeting/validate/SpotValidate.java new file mode 100644 index 00000000..89151074 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/validate/SpotValidate.java @@ -0,0 +1,24 @@ +package sevenstar.marineleisure.meeting.validate; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.meeting.error.MeetingError; +import sevenstar.marineleisure.meeting.error.SpotError; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@Component +@RequiredArgsConstructor +public class SpotValidate { + + private final OutdoorSpotRepository outdoorSpotSpotRepository; + + @Transactional(readOnly = true) + public OutdoorSpot foundOutdoorSpot(Long spotId){ + return outdoorSpotSpotRepository.findById(spotId) + .orElseThrow(() -> new CustomException(SpotError.SPOT_NOT_FOUND)); + } +} diff --git a/src/main/java/sevenstar/marineleisure/meeting/validate/TagValidate.java b/src/main/java/sevenstar/marineleisure/meeting/validate/TagValidate.java new file mode 100644 index 00000000..dfbc0c71 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/meeting/validate/TagValidate.java @@ -0,0 +1,22 @@ +package sevenstar.marineleisure.meeting.validate; + +import java.util.Optional; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.meeting.repository.TagRepository; +import sevenstar.marineleisure.meeting.domain.Tag; + +@Component +@RequiredArgsConstructor +public class TagValidate { + + private final TagRepository tagRepository; + + @Transactional(readOnly = true) + public Optional findByMeetingId(Long meetingId){ + return tagRepository.findByMeetingId(meetingId); + } +} diff --git a/src/main/java/sevenstar/marineleisure/member/controller/AuthController.java b/src/main/java/sevenstar/marineleisure/member/controller/AuthController.java new file mode 100644 index 00000000..0e6fc338 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/controller/AuthController.java @@ -0,0 +1,202 @@ +package sevenstar.marineleisure.member.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.global.domain.BaseResponse; +import sevenstar.marineleisure.global.exception.enums.MemberErrorCode; +import sevenstar.marineleisure.global.jwt.JwtTokenProvider; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.dto.AuthCodeRequest; +import sevenstar.marineleisure.member.dto.LoginResponse; +import sevenstar.marineleisure.member.service.AuthService; +import sevenstar.marineleisure.member.service.OauthService; + +/** + * 인증 관련 요청을 처리하는 컨트롤러 + */ +@Slf4j +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final OauthService oauthService; + private final AuthService authService; + private final JwtTokenProvider jwtTokenProvider; + + /** + * 카카오 로그인 URL 생성 + * + * @param redirectUri 커스텀 리다이렉트 URI (선택적) + * @return 카카오 로그인 URL과 state 값을 포함한 응답 + */ + @GetMapping("/kakao/url") + public ResponseEntity>> getKakaoLoginUrl( + @RequestParam(required = false) String redirectUri, + HttpServletRequest request + ) { + log.info("Generating Kakao login URL with redirectUri: {}", redirectUri); + Map loginUrlInfo = oauthService.getKakaoLoginUrl(redirectUri, request); + return BaseResponse.success(loginUrlInfo); + } + + /** + * 카카오 로그인 처리 (stateless) + * + * @param request 인증 코드 요청 DTO + * @param response HTTP 응답 + * @return 로그인 응답 DTO + */ + @PostMapping("/kakao/code") + public ResponseEntity> kakaoLogin( + @RequestBody AuthCodeRequest request, + HttpServletResponse response + ) { + log.info("Processing Kakao login with code: {}, state: {}, encryptedState: {}, error: {}, errorDescription: {}", + request.code(), request.state(), request.encryptedState(), request.error(), request.errorDescription()); + + // 에러 파라미터가 있는 경우 (사용자가 취소하거나 다른 에러가 발생한 경우) + if (request.error() != null && !request.error().isEmpty()) { + log.error("Kakao login error: {}, description: {}", request.error(), request.errorDescription()); + + // 사용자가 취소한 경우 (error=access_denied) + if ("access_denied".equals(request.error())) { + return BaseResponse.error(MemberErrorCode.KAKAO_LOGIN_CANCELED); + } else { + // 다른 에러인 경우 + return BaseResponse.error(MemberErrorCode.KAKAO_LOGIN_ERROR); + } + } + + try { + LoginResponse loginResponse = authService.processKakaoLogin( + request.code(), + request.state(), + request.encryptedState(), + response + ); + return BaseResponse.success(loginResponse); + } catch (AuthenticationException e) { + log.error("Authentication failed: {}", e.getMessage(), e); + return BaseResponse.error(MemberErrorCode.SECURITY_VALIDATION_FAILED); + } catch (Exception e) { + log.error("Kakao login failed: {}", e.getMessage(), e); + return BaseResponse.error(MemberErrorCode.KAKAO_LOGIN_ERROR); + } + } + + /** + * 토큰 재발급 + * + * @param refreshToken 리프레시 토큰 (쿠키 또는 요청 본문에서 추출) + * @param refreshTokenFromBody 요청 본문에서 전달된 리프레시 토큰 (jwt.use-cookie=false 설정용) + * @param response HTTP 응답 + * @return 새로운 액세스 토큰과 사용자 정보 + */ + @PostMapping("/refresh") + public ResponseEntity> refreshToken( + @CookieValue(value = "refresh_token", required = false) String refreshToken, + @RequestBody(required = false) Map refreshTokenFromBody, + HttpServletResponse response + ) { + log.info("Refreshing token"); + + try { + String token = refreshToken; + + // jwt.use-cookie=false 설정일 때는 요청 본문에서 리프레시 토큰 추출 + if ((token == null || token.isEmpty()) && refreshTokenFromBody != null) { + token = refreshTokenFromBody.get("refreshToken"); + log.info("Using refresh token from request body: {}", token); + } + + // 리프레시 토큰이 없는 경우 + if (token == null || token.isEmpty()) { + log.error("Empty refresh token"); + return BaseResponse.error(MemberErrorCode.REFRESH_TOKEN_MISSING); + } + + LoginResponse loginResponse = authService.refreshToken(token, response); + return BaseResponse.success(loginResponse); + } catch (IllegalArgumentException e) { + log.info("Invalid refresh token: {}", e.getMessage()); + return BaseResponse.error(MemberErrorCode.REFRESH_TOKEN_INVALID); + } catch (Exception e) { + log.error("Token refresh failed: {}", e.getMessage(), e); + return BaseResponse.error(MemberErrorCode.TOKEN_REFRESH_ERROR); + } + } + + /** + * 로그아웃 + * + * @param refreshToken 리프레시 토큰 (쿠키에서 추출) + * @param response HTTP 응답 + * @return 성공 응답 + */ + @PostMapping("/logout") + public ResponseEntity> logout( + @CookieValue(value = "refresh_token", required = false) String refreshToken, + HttpServletResponse response + ) { + log.info("Logging out with refresh token: {}", refreshToken); + + try { + authService.logout(refreshToken, response); + return BaseResponse.success(null); + } catch (Exception e) { + log.error("Logout failed: {}", e.getMessage(), e); + return BaseResponse.error(MemberErrorCode.LOGOUT_ERROR); + } + } + + + /** + * 테스트용 JWT 액세스 토큰 생성 + * 카카오 웹사이트에서 직접 발급받은 액세스 토큰으로 JWT 토큰 생성 + * + * @param kakaoAccessToken 카카오 액세스 토큰 + * @return JWT 액세스 토큰과 사용자 정보 + */ + @PostMapping("/kakao/test-jwt") + public ResponseEntity> createTestJwtToken( + @RequestParam String kakaoAccessToken + ) { + log.info("Creating test JWT token with Kakao access token"); + + try { + // 카카오 액세스 토큰으로 사용자 정보 조회 및 Member 객체 생성/조회 + Member member = oauthService.processKakaoUser(kakaoAccessToken); + + // JWT 액세스 토큰 생성 + String jwtAccessToken = jwtTokenProvider.createAccessToken(member); + + // 로그인 응답 생성 + LoginResponse loginResponse = LoginResponse.builder() + .accessToken(jwtAccessToken) + .email(member.getEmail()) + .userId(member.getId()) + .nickname(member.getNickname()) + .build(); + + return BaseResponse.success(loginResponse); + } catch (Exception e) { + log.error("Failed to create test JWT token: {}", e.getMessage(), e); + return BaseResponse.error(MemberErrorCode.KAKAO_LOGIN_ERROR); + } + } +} diff --git a/src/main/java/sevenstar/marineleisure/member/controller/MemberController.java b/src/main/java/sevenstar/marineleisure/member/controller/MemberController.java new file mode 100644 index 00000000..db83defa --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/controller/MemberController.java @@ -0,0 +1,125 @@ +package sevenstar.marineleisure.member.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import sevenstar.marineleisure.global.domain.BaseResponse; +import sevenstar.marineleisure.global.util.CurrentUserUtil; +import sevenstar.marineleisure.member.dto.MemberDetailResponse; +import sevenstar.marineleisure.member.dto.MemberLocationUpdateRequest; +import sevenstar.marineleisure.member.dto.MemberNicknameUpdateRequest; +import sevenstar.marineleisure.member.dto.MemberStatusUpdateRequest; +import sevenstar.marineleisure.member.service.MemberService; + +/** + * 회원 관련 요청을 처리하는 컨트롤러 + */ +@Slf4j +@RestController +@RequestMapping("/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + /** + * 현재 로그인한 회원의 상세 정보를 조회합니다. + * + * @return 회원 상세 정보 응답 + */ + @GetMapping("/me") + public ResponseEntity> getCurrentMemberDetail() { + log.info("현재 로그인한 회원 상세 정보 조회 요청"); + + // 현재 인증된 사용자의 ID 가져오기 + Long currentUserId = CurrentUserUtil.getCurrentUserId(); + + // 회원 상세 정보 조회 + MemberDetailResponse memberDetail = memberService.getCurrentMemberDetail(currentUserId); + + return BaseResponse.success(memberDetail); + } + + /** + * 현재 로그인한 회원의 닉네임을 업데이트합니다. + * + * @param request 닉네임 업데이트 요청 DTO + * @return 업데이트된 회원 상세 정보 응답 + */ + @PutMapping("/me") + public ResponseEntity> updateMemberNickname( + @RequestBody MemberNicknameUpdateRequest request) { + log.info("회원 닉네임 업데이트 요청: {}", request.getNickname()); + + // 현재 인증된 사용자의 ID 가져오기 + Long currentUserId = CurrentUserUtil.getCurrentUserId(); + + // 닉네임 업데이트 + MemberDetailResponse updatedMember = memberService.updateMemberNickname(currentUserId, request.getNickname()); + + return BaseResponse.success(updatedMember); + } + + /** + * 현재 로그인한 회원의 위치 정보를 업데이트합니다. + * + * @param request 위치 정보 업데이트 요청 DTO + * @return 업데이트된 회원 상세 정보 응답 + */ + @PutMapping("/me/location") + public ResponseEntity> updateMemberLocation( + @RequestBody MemberLocationUpdateRequest request) { + log.info("회원 위치 정보 업데이트 요청: latitude={}, longitude={}", + request.getLatitude(), request.getLongitude()); + + // 현재 인증된 사용자의 ID 가져오기 + Long currentUserId = CurrentUserUtil.getCurrentUserId(); + + // 위치 정보 업데이트 + MemberDetailResponse updatedMember = memberService.updateMemberLocation( + currentUserId, request.getLatitude(), request.getLongitude()); + + return BaseResponse.success(updatedMember); + } + + /** + * 현재 로그인한 회원의 상태를 업데이트합니다. + * + * @param request 상태 업데이트 요청 DTO + * @return 업데이트된 회원 상세 정보 응답 + */ + @PatchMapping("/me/status") + public ResponseEntity> updateMemberStatus( + @RequestBody MemberStatusUpdateRequest request) { + log.info("회원 상태 업데이트 요청: {}", request.getStatus()); + + // 현재 인증된 사용자의 ID 가져오기 + Long currentUserId = CurrentUserUtil.getCurrentUserId(); + + // 상태 업데이트 + MemberDetailResponse updatedMember = memberService.updateMemberStatus( + currentUserId, request.getStatus()); + + return BaseResponse.success(updatedMember); + } + + /** + * 현재 로그인한 회원을 소프트 삭제합니다 (상태를 EXPIRED로 변경). + * 액세스 토큰을 통해 인증된 사용자만 자신의 계정을 삭제할 수 있습니다. + * + * @return 삭제 성공 메시지 + */ + @PostMapping("/delete") + public ResponseEntity> deleteMember() { + // 현재 인증된 사용자의 ID 가져오기 + Long currentUserId = CurrentUserUtil.getCurrentUserId(); + log.info("회원 소프트 삭제 요청: 현재 인증된 사용자 ID={}", currentUserId); + + memberService.deleteMember(currentUserId); + + return BaseResponse.success("회원이 성공적으로 삭제되었습니다."); + } +} diff --git a/src/main/java/sevenstar/marineleisure/member/domain/Member.java b/src/main/java/sevenstar/marineleisure/member/domain/Member.java new file mode 100644 index 00000000..5c6b33cc --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/domain/Member.java @@ -0,0 +1,89 @@ +package sevenstar.marineleisure.member.domain; + +import java.math.BigDecimal; +import java.util.Objects; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.MemberStatus; + +@Entity +@Getter +@Table(name = "members") +@NoArgsConstructor +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 20, unique = true) + private String nickname; + + @Column(nullable = false, length = 50, unique = true) + private String email; + + private String provider; + @Column(name = "provider_id") + private String providerId; + + @Column(nullable = false) + private MemberStatus status = MemberStatus.ACTIVE; + + @Column(precision = 9, scale = 6) + private BigDecimal latitude; + + @Column(precision = 9, scale = 6) + private BigDecimal longitude; + + @Builder + public Member(String nickname, String email, String provider, String providerId, + BigDecimal latitude, BigDecimal longitude) { + this.nickname = nickname; + this.email = email; + this.provider = provider; + this.providerId = providerId; + this.latitude = latitude; + this.longitude = longitude; + } + + public void updateNickname(String newNickname) { + if (!Objects.equals(this.nickname, newNickname)) { + this.nickname = newNickname; + } + } + + /** + * 회원의 상태를 업데이트합니다. + * + * @param newStatus 새 상태 + */ + public void updateStatus(MemberStatus newStatus) { + if (this.status != newStatus) { + this.status = newStatus; + } + } + + /** + * 회원의 위치 정보를 업데이트합니다. + * + * @param newLatitude 새 위도 + * @param newLongitude 새 경도 + */ + public void updateLocation(BigDecimal newLatitude, BigDecimal newLongitude) { + if (newLatitude != null && (this.latitude == null || !this.latitude.equals(newLatitude))) { + this.latitude = newLatitude; + } + if (newLongitude != null && (this.longitude == null || !this.longitude.equals(newLongitude))) { + this.longitude = newLongitude; + } + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/member/domain/RefreshToken.java b/src/main/java/sevenstar/marineleisure/member/domain/RefreshToken.java new file mode 100644 index 00000000..8ce8e7c4 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/domain/RefreshToken.java @@ -0,0 +1,31 @@ +package sevenstar.marineleisure.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; + +@Entity +@Getter +@Table(name = "refresh_tokens") +@NoArgsConstructor +public class RefreshToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 512) + private String refreshToken; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private boolean expired = false; +} diff --git a/src/main/java/sevenstar/marineleisure/member/domain/Role.java b/src/main/java/sevenstar/marineleisure/member/domain/Role.java new file mode 100644 index 00000000..b900d8bf --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/domain/Role.java @@ -0,0 +1,14 @@ +package sevenstar.marineleisure.member.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + GUEST("ROLE_GUEST", "일반 유저"), + OWNER("ROlE_OWNER", "모임 생성자"); + + private final String key; + private final String value; +} diff --git a/src/main/java/sevenstar/marineleisure/member/dto/AuthCodeRequest.java b/src/main/java/sevenstar/marineleisure/member/dto/AuthCodeRequest.java new file mode 100644 index 00000000..bdf7d52e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/dto/AuthCodeRequest.java @@ -0,0 +1,19 @@ +package sevenstar.marineleisure.member.dto; + +/** + * 브라우저가 받은 인증 코드를 서버로 전달하기 위한 DTO + * + * @param code : 프론트엔드에서 받을 인증 코드 (성공 시) + * @param state : 프론트엔드에서 받을 상태 + * @param encryptedState : 암호화된 상태 값 (stateless 인증을 위해 사용) + * @param error : 인증 실패 시 반환되는 에러 코드 + * @param errorDescription : 인증 실패 시 반환되는 에러 메시지 + */ +public record AuthCodeRequest( + String code, + String state, + String encryptedState, + String error, + String errorDescription +) { +} diff --git a/src/main/java/sevenstar/marineleisure/member/dto/KakaoTokenResponse.java b/src/main/java/sevenstar/marineleisure/member/dto/KakaoTokenResponse.java new file mode 100644 index 00000000..5f96d4de --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/dto/KakaoTokenResponse.java @@ -0,0 +1,29 @@ +package sevenstar.marineleisure.member.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Builder; + +/** + * + * @param accessToken + * @param tokenType + * @param refreshToken + * @param expiresIn + * @param scope + * @param refreshTokenExpiresIn + */ +@Builder +public record KakaoTokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("expires_in") Long expiresIn, + @JsonProperty("scope") String scope, + @JsonProperty("refresh_token_expires_in") Long refreshTokenExpiresIn +) { + @JsonCreator + public KakaoTokenResponse { + } +} diff --git a/src/main/java/sevenstar/marineleisure/member/dto/LoginResponse.java b/src/main/java/sevenstar/marineleisure/member/dto/LoginResponse.java new file mode 100644 index 00000000..1c7a2dac --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/dto/LoginResponse.java @@ -0,0 +1,73 @@ +package sevenstar.marineleisure.member.dto; + +import lombok.Builder; +import sevenstar.marineleisure.member.domain.Member; + +/** + * 로그인 성공 시 반환되는 DTO + * Access 토큰과 사용자 정보를 포함 + * Refresh 토큰은 jwt.use-cookie 설정에 따라 쿠키 또는 응답 본문으로 전송 + * @param accessToken + * @param userId + * @param email + * @param nickname + * @param refreshToken jwt.use-cookie=false 설정일 때만 사용 (쿠키 대신 응답 본문에 포함) + */ +@Builder +public record LoginResponse( + String accessToken, + Long userId, + String email, + String nickname, + String refreshToken +) { + /** + * 쿠키 방식 사용 시 (jwt.use-cookie=true) 생성자 + */ + public static LoginResponse of(String accessToken, Long userId, String email, String nickname) { + return LoginResponse.builder() + .accessToken(accessToken) + .userId(userId) + .email(email) + .nickname(nickname) + .build(); + } + + /** + * JSON 응답 방식 사용 시 (jwt.use-cookie=false) 생성자 + */ + public static LoginResponse of(String accessToken, Long userId, String email, String nickname, String refreshToken) { + return LoginResponse.builder() + .accessToken(accessToken) + .userId(userId) + .email(email) + .nickname(nickname) + .refreshToken(refreshToken) + .build(); + } + + /** + * 사용자 정보와 액세스 토큰만으로 생성하는 편의 메서드 + */ + public static LoginResponse of(String accessToken, Member member) { + return LoginResponse.builder() + .accessToken(accessToken) + .userId(member.getId()) + .email(member.getEmail()) + .nickname(member.getNickname()) + .build(); + } + + /** + * 사용자 정보와 액세스 토큰, 리프레시 토큰으로 생성하는 편의 메서드 (jwt.use-cookie=false 설정용) + */ + public static LoginResponse of(String accessToken, Member member, String refreshToken) { + return LoginResponse.builder() + .accessToken(accessToken) + .userId(member.getId()) + .email(member.getEmail()) + .nickname(member.getNickname()) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/sevenstar/marineleisure/member/dto/MemberDetailResponse.java b/src/main/java/sevenstar/marineleisure/member/dto/MemberDetailResponse.java new file mode 100644 index 00000000..0c6bad41 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/dto/MemberDetailResponse.java @@ -0,0 +1,21 @@ +package sevenstar.marineleisure.member.dto; + +import lombok.Builder; +import lombok.Getter; +import sevenstar.marineleisure.global.enums.MemberStatus; + +import java.math.BigDecimal; + +/** + * 회원 상세 정보 응답 DTO + */ +@Getter +@Builder +public class MemberDetailResponse { + private Long id; + private String email; + private String nickname; + private MemberStatus status; + private BigDecimal latitude; + private BigDecimal longitude; +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/member/dto/MemberLocationUpdateRequest.java b/src/main/java/sevenstar/marineleisure/member/dto/MemberLocationUpdateRequest.java new file mode 100644 index 00000000..05eddf91 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/dto/MemberLocationUpdateRequest.java @@ -0,0 +1,18 @@ +package sevenstar.marineleisure.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 회원 위치 정보 업데이트 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MemberLocationUpdateRequest { + private BigDecimal latitude; + private BigDecimal longitude; +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/member/dto/MemberNicknameUpdateRequest.java b/src/main/java/sevenstar/marineleisure/member/dto/MemberNicknameUpdateRequest.java new file mode 100644 index 00000000..c68dfb40 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/dto/MemberNicknameUpdateRequest.java @@ -0,0 +1,15 @@ +package sevenstar.marineleisure.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 회원 닉네임 업데이트 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MemberNicknameUpdateRequest { + private String nickname; +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/member/dto/MemberStatusUpdateRequest.java b/src/main/java/sevenstar/marineleisure/member/dto/MemberStatusUpdateRequest.java new file mode 100644 index 00000000..4120df50 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/dto/MemberStatusUpdateRequest.java @@ -0,0 +1,17 @@ +package sevenstar.marineleisure.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import sevenstar.marineleisure.global.enums.MemberStatus; + +/** + * 회원 상태 업데이트 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MemberStatusUpdateRequest { + private MemberStatus status; +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/member/repository/MemberRepository.java b/src/main/java/sevenstar/marineleisure/member/repository/MemberRepository.java new file mode 100644 index 00000000..64ea2924 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/repository/MemberRepository.java @@ -0,0 +1,31 @@ +package sevenstar.marineleisure.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import sevenstar.marineleisure.global.enums.MemberStatus; +import org.springframework.stereotype.Repository; + +import sevenstar.marineleisure.member.domain.Member; + +import java.time.LocalDateTime; +import java.util.Optional; +@Repository +public interface MemberRepository extends JpaRepository { +// Optional findByUserNickname(String username); + Optional findByProviderAndProviderId(String provider, String providerId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(""" + DELETE FROM Member m + WHERE m.status = :status + AND m.updatedAt < :expired + """) + int deleteByStatusAndUpdatedAtBefore(@Param("status") MemberStatus memberStatus, + @Param("expired") LocalDateTime expired); + + boolean existsById(Long id); +} diff --git a/src/main/java/sevenstar/marineleisure/member/service/AuthService.java b/src/main/java/sevenstar/marineleisure/member/service/AuthService.java new file mode 100644 index 00000000..1fff8c7e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/service/AuthService.java @@ -0,0 +1,152 @@ +package sevenstar.marineleisure.member.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.global.jwt.JwtTokenProvider; +import sevenstar.marineleisure.global.util.CookieUtil; +import sevenstar.marineleisure.global.util.StateEncryptionUtil; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.dto.KakaoTokenResponse; +import sevenstar.marineleisure.member.dto.LoginResponse; + +/** + * 인증 관련 비즈니스 로직을 처리하는 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtTokenProvider jwtTokenProvider; + private final OauthService oauthService; + private final CookieUtil cookieUtil; + private final StateEncryptionUtil stateEncryptionUtil; + + @Value("${jwt.use-cookie:true}") + private boolean useCookie; + + /** + * 카카오 로그인 처리 (stateless) + * + * @param code 인증 코드 + * @param state OAuth state 파라미터 + * @param encryptedState 암호화된 state 값 + * @param response HTTP 응답 + * @return 로그인 응답 DTO + */ + public LoginResponse processKakaoLogin(String code, String state, String encryptedState, + HttpServletResponse response) { + // 0. state 검증 (stateless) + log.info("Validating OAuth state: received={}, encrypted={}", state, encryptedState); + + if (!stateEncryptionUtil.validateState(state, encryptedState)) { + log.error("State validation failed: possible CSRF attack"); + throw new BadCredentialsException("Possible CSRF attack: state parameter doesn't match"); + } + + // 1. 인증 코드로 카카오 토큰 교환 + KakaoTokenResponse tokenResponse = oauthService.exchangeCodeForToken(code); + + // 2. 카카오 토큰으로 사용자 정보 요청 및 처리 + String accessToken = tokenResponse != null ? tokenResponse.accessToken() : null; + if (accessToken == null) { + log.error("Failed to get access token from Kakao"); + throw new RuntimeException("Failed to get access token from Kakao"); + } + + // 3. 사용자 정보 처리 및 회원 조회 + Member member = oauthService.processKakaoUser(accessToken); + + // 4. JWT 토큰 생성 + String jwtAccessToken = jwtTokenProvider.createAccessToken(member); + String refreshToken = jwtTokenProvider.createRefreshToken(member); + + // 5. jwt.use-cookie 설정에 따라 리프레시 토큰 전달 방식 결정 + if (useCookie) { + // useCookie=true: 쿠키로 전송 + log.debug("Using cookie for refresh token (useCookie=true)"); + cookieUtil.addCookie(response, cookieUtil.createRefreshTokenCookie(refreshToken)); + return LoginResponse.of(jwtAccessToken, member); + } else { + // useCookie=false: JSON 응답으로 전송 + log.debug("Using JSON response for refresh token (useCookie=false)"); + return LoginResponse.of(jwtAccessToken, member, refreshToken); + } + } + + /** + * 토큰 재발급 + * + * @param refreshToken 리프레시 토큰 + * @param response HTTP 응답 + * @return 로그인 응답 DTO + */ + public LoginResponse refreshToken(String refreshToken, HttpServletResponse response) { + // 1. 리프레시 토큰 검증 + if (refreshToken == null || refreshToken.isEmpty()) { + log.error("Empty refresh token"); + throw new IllegalArgumentException("리프레시 토큰이 없습니다."); + } + + if (!jwtTokenProvider.validateRefreshToken(refreshToken)) { + log.info("Invalid refresh token: {}", refreshToken); + throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); + } + + // 2. 토큰에서 사용자 ID 추출 및 회원 조회 + Long memberId = jwtTokenProvider.getMemberId(refreshToken); + log.info("Refreshing token for userId: {}", memberId); + Member member = oauthService.findUserById(memberId); + + // 3. 기존 리프레시 토큰 블랙리스트에 추가 + jwtTokenProvider.blacklistRefreshToken(refreshToken); + + // 4. 새 토큰 발급 + String newAccessToken = jwtTokenProvider.createAccessToken(member); + String newRefreshToken = jwtTokenProvider.createRefreshToken(member); + + // 5. jwt.use-cookie 설정에 따라 리프레시 토큰 전달 방식 결정 + if (useCookie) { + // useCookie=true: 쿠키로 전송 + log.debug("Using cookie for refresh token (useCookie=true)"); + cookieUtil.addCookie(response, cookieUtil.createRefreshTokenCookie(newRefreshToken)); + return LoginResponse.of(newAccessToken, member); + } else { + // useCookie=false: JSON 응답으로 전송 + log.debug("Using JSON response for refresh token (useCookie=false)"); + return LoginResponse.of(newAccessToken, member, newRefreshToken); + } + } + + /** + * 로그아웃 + * + * @param refreshToken 리프레시 토큰 + * @param response HTTP 응답 + */ + public void logout(String refreshToken, HttpServletResponse response) { + log.info("Logging out with refresh token: {}", refreshToken); + + // 1. 리프레시 토큰이 있다면 블랙리스트에 추가 + if (refreshToken != null && !refreshToken.isEmpty()) { + try { + jwtTokenProvider.blacklistRefreshToken(refreshToken); + log.info("리프레시 토큰 블랙리스트 추가 성공"); + } catch (Exception e) { + log.error("리프레시 토큰 블랙리스트 추가 실패: {}", e.getMessage()); + } + } + + // 2. 리프레시 토큰 쿠키 삭제 + cookieUtil.addCookie(response, cookieUtil.deleteRefreshTokenCookie()); + + log.info("로그아웃 성공"); + } + + // createLoginResponse 메서드는 LoginResponse.of() 정적 팩토리 메서드로 대체. +} diff --git a/src/main/java/sevenstar/marineleisure/member/service/MemberService.java b/src/main/java/sevenstar/marineleisure/member/service/MemberService.java new file mode 100644 index 00000000..1cfc7cf4 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/service/MemberService.java @@ -0,0 +1,231 @@ +package sevenstar.marineleisure.member.service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.NoSuchElementException; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.global.enums.MemberStatus; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.MemberErrorCode; +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Participant; +import sevenstar.marineleisure.meeting.repository.MeetingRepository; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.dto.MemberDetailResponse; +import sevenstar.marineleisure.member.repository.MemberRepository; + +/** + * 회원 관련 비즈니스 로직을 처리하는 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final MeetingRepository meetingRepository; + private final ParticipantRepository participantRepository; + + /** + * 회원 ID로 회원 상세 정보를 조회합니다. + * + * @param memberId 회원 ID + * @return 회원 상세 정보 응답 DTO + * @throws NoSuchElementException 회원을 찾을 수 없는 경우 + */ + public MemberDetailResponse getMemberDetail(Long memberId) { + log.info("회원 상세 정보 조회: memberId={}", memberId); + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + + return MemberDetailResponse.builder() + .id(member.getId()) + .email(member.getEmail()) + .nickname(member.getNickname()) + .status(member.getStatus()) + .latitude(member.getLatitude()) + .longitude(member.getLongitude()) + .build(); + } + + /** + * 현재 로그인한 회원의 상세 정보를 조회합니다. + * 이 메서드는 CurrentUserUtil을 통해 현재 인증된 사용자의 ID를 가져와 사용합니다. + * + * @param memberId 회원 ID + * @return 회원 상세 정보 응답 DTO + * @throws NoSuchElementException 회원을 찾을 수 없는 경우 + */ + public MemberDetailResponse getCurrentMemberDetail(Long memberId) { + log.info("현재 로그인한 회원 상세 정보 조회: memberId={}", memberId); + return getMemberDetail(memberId); + } + + /** + * 회원의 닉네임을 업데이트합니다. + * + * @param memberId 회원 ID + * @param nickname 새 닉네임 + * @return 업데이트된 회원 상세 정보 응답 DTO + * @throws NoSuchElementException 회원을 찾을 수 없는 경우 + */ + @Transactional + public MemberDetailResponse updateMemberNickname(Long memberId, String nickname) { + log.info("회원 닉네임 업데이트: memberId={}, nickname={}", memberId, nickname); + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + + member.updateNickname(nickname); + Member updatedMember = memberRepository.save(member); + + return MemberDetailResponse.builder() + .id(updatedMember.getId()) + .email(updatedMember.getEmail()) + .nickname(updatedMember.getNickname()) + .status(updatedMember.getStatus()) + .latitude(updatedMember.getLatitude()) + .longitude(updatedMember.getLongitude()) + .build(); + } + + /** + * 회원의 위치 정보를 업데이트합니다. + * + * @param memberId 회원 ID + * @param latitude 위도 + * @param longitude 경도 + * @return 업데이트된 회원 상세 정보 응답 DTO + * @throws NoSuchElementException 회원을 찾을 수 없는 경우 + */ + @Transactional + public MemberDetailResponse updateMemberLocation(Long memberId, BigDecimal latitude, BigDecimal longitude) { + log.info("회원 위치 정보 업데이트: memberId={}, latitude={}, longitude={}", memberId, latitude, longitude); + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 위치 정보 업데이트 로직 (Member 클래스에 해당 메서드 추가 필요) + updateMemberLocationFields(member, latitude, longitude); + Member updatedMember = memberRepository.save(member); + + return MemberDetailResponse.builder() + .id(updatedMember.getId()) + .email(updatedMember.getEmail()) + .nickname(updatedMember.getNickname()) + .status(updatedMember.getStatus()) + .latitude(updatedMember.getLatitude()) + .longitude(updatedMember.getLongitude()) + .build(); + } + + /** + * 회원의 상태를 업데이트합니다. + * + * @param memberId 회원 ID + * @param status 새 상태 + * @return 업데이트된 회원 상세 정보 응답 DTO + * @throws NoSuchElementException 회원을 찾을 수 없는 경우 + */ + @Transactional + public MemberDetailResponse updateMemberStatus(Long memberId, MemberStatus status) { + log.info("회원 상태 업데이트: memberId={}, status={}", memberId, status); + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 상태 업데이트 로직 (Member 클래스에 해당 메서드 추가 필요) + updateMemberStatusField(member, status); + Member updatedMember = memberRepository.save(member); + + return MemberDetailResponse.builder() + .id(updatedMember.getId()) + .email(updatedMember.getEmail()) + .nickname(updatedMember.getNickname()) + .status(updatedMember.getStatus()) + .latitude(updatedMember.getLatitude()) + .longitude(updatedMember.getLongitude()) + .build(); + } + + /** + * 회원을 탈퇴 처리합니다. + * 1. 회원이 호스트인 경우 해당 미팅을 삭제합니다. + * 2. 회원이 게스트인 경우 참가자 목록에서 삭제합니다. + * 3. 회원 상태를 EXPIRED로 변경합니다 (소프트 삭제). + * + * @param memberId 회원 ID + * @throws NoSuchElementException 회원을 찾을 수 없는 경우 + */ + @Transactional + public void deleteMember(Long memberId) { + log.info("회원 탈퇴 처리: memberId={}", memberId); + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 1. 회원이 호스트인 경우 해당 미팅을 삭제 + List hostedMeetings = meetingRepository.findByHostId(memberId); + if (!hostedMeetings.isEmpty()) { + log.info("호스트로 등록된 미팅 삭제: memberId={}, meetingCount={}", memberId, hostedMeetings.size()); + meetingRepository.deleteAll(hostedMeetings); + } + + // 2. 회원이 게스트인 경우 참가자 목록에서 삭제 + List participations = participantRepository.findByUserId(memberId); + if (!participations.isEmpty()) { + log.info("참가자 목록에서 삭제: memberId={}, participationCount={}", memberId, participations.size()); + participantRepository.deleteAll(participations); + } + + // 3. 회원 상태를 EXPIRED로 변경 (실제 삭제 대신 소프트 삭제 방식 사용) + updateMemberStatusField(member, MemberStatus.EXPIRED); + memberRepository.save(member); + + log.info("회원 탈퇴 처리 완료: memberId={}", memberId); + } + + @Scheduled(cron = "0 0 0 * * ?", zone = "Asia/Seoul") + @Transactional + public void deleteExpiredMember() { + LocalDateTime expired = LocalDateTime.now().minusDays(30); + try { + int deleteCnt = memberRepository.deleteByStatusAndUpdatedAtBefore(MemberStatus.EXPIRED, expired); + log.info("[Scheduler] deleted expired member: count={}", deleteCnt); + } catch (Exception e) { + log.error("[Scheduler] failed to delete expired member: {}", e.getMessage()); + } + } + /** + * 회원의 위치 정보를 업데이트합니다. + * 이 메서드는 Member 엔티티의 updateLocation 메서드를 사용합니다. + * + * @param member 회원 엔티티 + * @param latitude 위도 + * @param longitude 경도 + */ + private void updateMemberLocationFields(Member member, BigDecimal latitude, BigDecimal longitude) { + member.updateLocation(latitude, longitude); + } + + /** + * 회원의 상태를 업데이트합니다. + * 이 메서드는 Member 엔티티의 updateStatus 메서드를 사용합니다. + * + * @param member 회원 엔티티 + * @param status 새 상태 + */ + private void updateMemberStatusField(Member member, MemberStatus status) { + member.updateStatus(status); + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/member/service/OauthService.java b/src/main/java/sevenstar/marineleisure/member/service/OauthService.java new file mode 100644 index 00000000..492a6a6c --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/member/service/OauthService.java @@ -0,0 +1,181 @@ +package sevenstar.marineleisure.member.service; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.global.util.StateEncryptionUtil; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.dto.KakaoTokenResponse; +import sevenstar.marineleisure.member.repository.MemberRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OauthService { + + private final MemberRepository memberRepository; + private final WebClient webClient; + private final StateEncryptionUtil stateEncryptionUtil; + + @Value("${kakao.login.api_key}") + private String apiKey; + + @Value("${kakao.login.client_secret}") + private String clientSecret; + + @Value("${kakao.login.uri.base}") + private String kakaoBaseUri; + + @Value("${kakao.login.redirect_uri}") + private String redirectUri; + + /** + * 카카오 로그인 URL 생성 (stateless) + * + * @param customRedirectUri 커스텀 리다이렉트 URI (null인 경우 기본값 사용) + * @return 카카오 로그인 URL, state 값, 암호화된 state 값을 포함한 Map + */ + public Map getKakaoLoginUrl(String customRedirectUri) { + String state = UUID.randomUUID().toString(); + String encryptedState = stateEncryptionUtil.encryptState(state); + + log.info("Generated OAuth state: {} (encrypted: {})", state, encryptedState); + + // Use the provided redirectUri or fall back to the configured one + String finalRedirectUri = customRedirectUri != null ? customRedirectUri : this.redirectUri; + + String kakaoAuthUrl = UriComponentsBuilder.fromUriString(kakaoBaseUri) + .path("/oauth/authorize") + .queryParam("client_id", apiKey) + .queryParam("redirect_uri", finalRedirectUri) + .queryParam("response_type", "code") + .queryParam("state", state) + .build() + .toUriString(); + + return Map.of( + "kakaoAuthUrl", kakaoAuthUrl, + "state", state, + "encryptedState", encryptedState + ); + } + + /** + * 카카오 로그인 URL 생성 (stateless - HttpServletRequest 호환용) + * + * @param customRedirectUri 커스텀 리다이렉트 URI (null인 경우 기본값 사용) + * @param request HTTP 요청 (호환성을 위해 유지, 사용하지 않음) + * @return 카카오 로그인 URL, state 값, 암호화된 state 값을 포함한 Map + */ + public Map getKakaoLoginUrl(String customRedirectUri, HttpServletRequest request) { + // 세션 사용하지 않고 stateless 방식으로 구현 + return getKakaoLoginUrl(customRedirectUri); + } + + /** + * 카카오 인증 코드로 토큰 교환 + * + * @param code 인증 코드 + * @return 카카오 토큰 응답 + */ + public KakaoTokenResponse exchangeCodeForToken(String code) { + String tokenUrl = UriComponentsBuilder.fromUriString(kakaoBaseUri) + .path("/oauth/token") + .build() + .toUriString(); + + log.info("Exchanging authorization code for token with redirect URI: {}", redirectUri); + log.info("Authorization code: {}", code); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", apiKey); + params.add("redirect_uri", redirectUri); + params.add("code", code); + params.add("client_secret", clientSecret); + + return webClient.post() + .uri(tokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(BodyInserters.fromFormData(params)) + .retrieve() + .bodyToMono(KakaoTokenResponse.class) + .block(); + } + + @Transactional + public Member processKakaoUser(String accessToken) { + // 1. access token으로 사용자 정보 요청 + Map memberAttributes = getUserInfo(accessToken); + // 2. 사용자 정보로 회원가입 or 로그인 처리 + return saveOrUpdateKakaoUser(memberAttributes); + } + + /** + * 카카오 API로 사용자 정보 요청 + * + * @param accessToken + * @return + */ + private Map getUserInfo(String accessToken) { + return webClient.get() + .uri("https://kapi.kakao.com/v2/user/me") + .header("Authorization", "Bearer " + accessToken) + .header("Content-Type", "application/x-www-form-urlencoded;charset=utf-8") + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .block(); + } + + /** + * 카카오 사용자 정보로 회원가입 or 로그인 처리 + * + * @param memberAttributes + * @return + */ + private Member saveOrUpdateKakaoUser(Map memberAttributes) { + Long providerId = (Long)memberAttributes.get("id"); + Map kakaoAccount = (Map)memberAttributes.get("kakao_account"); + Map profile = (Map)kakaoAccount.get("profile"); + + String email = (String)kakaoAccount.get("email"); + String nickname = (String)profile.get("nickname"); + + // 기존 회원이 있으면 가져오고, 없으면 새로 생성 (Optional이 비어있을 때만 실행) + Member member = memberRepository.findByProviderAndProviderId("kakao", String.valueOf(providerId)) + .orElseGet(() -> Member.builder() + .provider("kakao") + .providerId(String.valueOf(providerId)) + .email(email) // 새 회원 생성 시 이메일 설정 + .nickname(nickname) // 새 회원 생성 시 닉네임 설정 + .latitude(BigDecimal.ZERO) + .longitude(BigDecimal.ZERO) + .build()); + member.updateNickname(nickname); + + return memberRepository.save(member); + } + + public Member findUserById(Long id) { + return memberRepository.findById(id) + .orElseThrow( + () -> new NoSuchElementException("User not found for id: " + id + " or email: " + id + "@kakao.com")); + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/observatory/domain/Observatory.java b/src/main/java/sevenstar/marineleisure/observatory/domain/Observatory.java new file mode 100644 index 00000000..596d00d6 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/observatory/domain/Observatory.java @@ -0,0 +1,44 @@ +package sevenstar.marineleisure.observatory.domain; + +import java.math.BigDecimal; +import java.time.LocalTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.HlCode; + +@Entity +@Getter +@Table(name = "observatories") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Observatory extends BaseEntity { + + // 관측소의 id는 바다누리 API의 관측소 obs_post_id에서 따왔습니다. + @Id + @Column(length = 7) + private String id; + + @Column(nullable = false) + private String name; + + @Column(precision = 9, scale = 6, nullable = false) + private BigDecimal latitude; + + @Column(precision = 9, scale = 6, nullable = false) + private BigDecimal longitude; + + @Column(name = "hl_code", nullable = false) + private HlCode hlCode; + + @Column(nullable = false) + private LocalTime time; + +} diff --git a/src/main/java/sevenstar/marineleisure/spot/config/GeoConfig.java b/src/main/java/sevenstar/marineleisure/spot/config/GeoConfig.java new file mode 100644 index 00000000..7033411a --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/config/GeoConfig.java @@ -0,0 +1,14 @@ +package sevenstar.marineleisure.spot.config; + +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.PrecisionModel; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class GeoConfig { + @Bean + GeometryFactory geometryFactory() { + return new GeometryFactory(new PrecisionModel(), 4326); + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/controller/SpotController.java b/src/main/java/sevenstar/marineleisure/spot/controller/SpotController.java new file mode 100644 index 00000000..ac67f25f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/controller/SpotController.java @@ -0,0 +1,43 @@ +package sevenstar.marineleisure.spot.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseResponse; +import sevenstar.marineleisure.spot.dto.detail.SpotDetailReadResponse; +import sevenstar.marineleisure.spot.dto.SpotPreviewReadResponse; +import sevenstar.marineleisure.spot.dto.SpotPreviewRequest; +import sevenstar.marineleisure.spot.dto.SpotReadRequest; +import sevenstar.marineleisure.spot.dto.SpotReadResponse; +import sevenstar.marineleisure.spot.service.SpotService; + +@RestController +@RequestMapping("/map/spots") +@RequiredArgsConstructor +public class SpotController { + private final SpotService spotService; + + @GetMapping + ResponseEntity> getSpots(@ModelAttribute @Valid SpotReadRequest request) { + return BaseResponse.success( + spotService.searchSpot(request.getLatitude(), request.getLongitude(), request.getRadius(), + request.getCategory())); + } + + @GetMapping("/{id}") + ResponseEntity> getSpotDetail(@PathVariable Long id) { + spotService.upsertSpotViewStats(id); + return BaseResponse.success(spotService.searchSpotDetail(id)); + } + + @GetMapping("/preview") + ResponseEntity> getSpotPreview(@ModelAttribute @Valid SpotPreviewRequest request) { + return BaseResponse.success(spotService.preview(request.getLatitude(), request.getLongitude())); + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/domain/OutdoorSpot.java b/src/main/java/sevenstar/marineleisure/spot/domain/OutdoorSpot.java new file mode 100644 index 00000000..37e5dcb6 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/domain/OutdoorSpot.java @@ -0,0 +1,73 @@ +package sevenstar.marineleisure.spot.domain; + +import java.math.BigDecimal; + +import org.locationtech.jts.geom.Point; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sevenstar.marineleisure.global.domain.BaseEntity; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.FishingType; + +@Entity +@Getter +@Table(name = "outdoor_spots", indexes = { + @Index(name = "idx_lat_lon", columnList = "latitude, longitude"), + @Index(name = "idx_point", columnList = "geo_point") +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_lat_lon_category", columnNames = {"latitude", "longitude", "category"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OutdoorSpot extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + private ActivityCategory category; + + @Enumerated(EnumType.STRING) + private FishingType type; + + @Column(length = 100) + private String location; + + @Column(precision = 9, scale = 6) + private BigDecimal latitude; + + @Column(precision = 9, scale = 6) + private BigDecimal longitude; + + @Column(name = "geo_point", columnDefinition = "POINT SRID 4326", + nullable = false) + private Point point; + + @Builder + public OutdoorSpot(String name, ActivityCategory category, FishingType type, String location, BigDecimal latitude, + BigDecimal longitude, Point point) { + this.name = name; + this.category = category; + this.type = type; + this.location = location; + this.latitude = latitude; + this.longitude = longitude; + this.point = point; + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/domain/SpotScore.java b/src/main/java/sevenstar/marineleisure/spot/domain/SpotScore.java new file mode 100644 index 00000000..2c0c9568 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/domain/SpotScore.java @@ -0,0 +1,21 @@ +package sevenstar.marineleisure.spot.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 이후 각 시/도에 기반한 프리셋 구현에 사용될 엔티티입니다 + * @author gunwoong + */ +// TODO : 기능 고도화에 사용될 프리셋 +@Entity +@Table(name = "spot_score") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SpotScore { + @Id + private Long spotId; + private Double score; +} diff --git a/src/main/java/sevenstar/marineleisure/spot/domain/SpotViewQuartile.java b/src/main/java/sevenstar/marineleisure/spot/domain/SpotViewQuartile.java new file mode 100644 index 00000000..4eb49b05 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/domain/SpotViewQuartile.java @@ -0,0 +1,33 @@ +package sevenstar.marineleisure.spot.domain; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "spot_view_quartile") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class SpotViewQuartile { + @Id + private Long spotId; + @Column(name = "month_quartile") + + private Integer monthQuartile; + @Column(name = "week_quartile") + private Integer weekQuartile; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public SpotViewQuartile(Integer monthQuartile, Integer weekQuartile) { + this.monthQuartile = monthQuartile; + this.weekQuartile = weekQuartile; + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/domain/SpotViewStats.java b/src/main/java/sevenstar/marineleisure/spot/domain/SpotViewStats.java new file mode 100644 index 00000000..59b77c5b --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/domain/SpotViewStats.java @@ -0,0 +1,26 @@ +package sevenstar.marineleisure.spot.domain; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; + +@Entity +@Table(name = "spot_view_stats") +@IdClass(SpotViewStatsId.class) +public class SpotViewStats { + + @Id + @Column(name = "spot_id", nullable = false) + private Long spotId; + + @Id + @Column(name = "view_date", nullable = false) + private LocalDate viewDate; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; +} diff --git a/src/main/java/sevenstar/marineleisure/spot/domain/SpotViewStatsId.java b/src/main/java/sevenstar/marineleisure/spot/domain/SpotViewStatsId.java new file mode 100644 index 00000000..e0125f26 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/domain/SpotViewStatsId.java @@ -0,0 +1,17 @@ +package sevenstar.marineleisure.spot.domain; + +import java.io.Serializable; +import java.time.LocalDate; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SpotViewStatsId implements Serializable { + private Long spotId; + private LocalDate viewDate; +} + diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/SpotDetailReadResponse.java b/src/main/java/sevenstar/marineleisure/spot/dto/SpotDetailReadResponse.java new file mode 100644 index 00000000..ee024c2e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/SpotDetailReadResponse.java @@ -0,0 +1,84 @@ +package sevenstar.marineleisure.spot.dto; + +import java.util.List; + +public record SpotDetailReadResponse( + Long id, + String name, + String category, + String location, + float latitude, + float longitude, + boolean isFavorite, + List detail +) { + + public record FishingSpotDetail( + String forecastDate, + String timePeriod, + int tide, + String totalIndex, + RangeDetail waveHeight, + RangeDetail seaTemp, + RangeDetail airTemp, + RangeDetail currentSpeed, + RangeDetail windSpeed, + int uvIndex, + FishDetail target + ) { + } + + public record SurfingSpotDetail( + String forecastDate, + String timePeriod, + float waveHeight, + int wavePeriod, + float windSpeed, + float seaTemp, + String totalIndex, + int uvIndex + ) { + } + + public record ScubaSpotDetail( + String forecastDate, + String timePeriod, + String sunrise, + String sunset, + String tide, + RangeDetail waveHeight, + RangeDetail seaTemp, + RangeDetail currentSpeed, + String totalIndex + ) { + + } + + public record MudflatSpotDetail( + String forecastDate, + String startTime, + String endTime, + RangeDetail airTemp, + RangeDetail windSpeed, + String weather, + String totalIndex, + int uvIndex + ) { + + } + + public record RangeDetail( + float min, + float max + ) { + + } + + public record FishDetail( + Long id, + String name + ) { + + } + +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/SpotPreviewReadResponse.java b/src/main/java/sevenstar/marineleisure/spot/dto/SpotPreviewReadResponse.java new file mode 100644 index 00000000..5d0ca9ce --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/SpotPreviewReadResponse.java @@ -0,0 +1,23 @@ +package sevenstar.marineleisure.spot.dto; + +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.spot.dto.projection.SpotPreviewProjection; + +public record SpotPreviewReadResponse( + SpotPreview fishing, + SpotPreview mudflat, + SpotPreview surfing, + SpotPreview scuba +) { + + public record SpotPreview( + Long spotId, + String name, + TotalIndex totalIndex + ) { + public static SpotPreview from(SpotPreviewProjection spotPreviewProjection) { + return new SpotPreview(spotPreviewProjection.getSpotId(), spotPreviewProjection.getName(), + spotPreviewProjection.getTotalIndex()); + } + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/SpotPreviewRequest.java b/src/main/java/sevenstar/marineleisure/spot/dto/SpotPreviewRequest.java new file mode 100644 index 00000000..3340492a --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/SpotPreviewRequest.java @@ -0,0 +1,24 @@ +package sevenstar.marineleisure.spot.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class SpotPreviewRequest { + @NotNull(message = "위도(latitude)는 필수입니다.") + @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다.") + @DecimalMax(value = "90.0", message = "위도는 90 이하이어야 합니다.") + private Float latitude; + + @NotNull(message = "경도(longitude)는 필수입니다.") + @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다.") + @DecimalMax(value = "180.0", message = "경도는 180 이하이어야 합니다.") + private Float longitude; + + public SpotPreviewRequest(Float latitude, Float longitude) { + this.latitude = latitude; + this.longitude = longitude; + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/SpotReadRequest.java b/src/main/java/sevenstar/marineleisure/spot/dto/SpotReadRequest.java new file mode 100644 index 00000000..bac93d90 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/SpotReadRequest.java @@ -0,0 +1,36 @@ +package sevenstar.marineleisure.spot.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import sevenstar.marineleisure.global.enums.ActivityCategory; + +@Getter +public class SpotReadRequest { + @NotNull(message = "위도(latitude)는 필수입니다.") + @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다.") + @DecimalMax(value = "90.0", message = "위도는 90 이하이어야 합니다.") + private Float latitude; + + @NotNull(message = "경도(longitude)는 필수입니다.") + @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다.") + @DecimalMax(value = "180.0", message = "경도는 180 이하이어야 합니다.") + private Float longitude; + + @NotNull(message = "반경은 필수입니다.") + @Positive(message = "반경은 양수여야 합니다.") + @Max(value = 1000,message = "반경은 1000km 이하여야 합니다.") + private Integer radius; + + private ActivityCategory category; + + public SpotReadRequest(Float latitude, Float longitude, Integer radius, ActivityCategory category) { + this.latitude = latitude; + this.longitude = longitude; + this.radius = radius; + this.category = category; + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/SpotReadResponse.java b/src/main/java/sevenstar/marineleisure/spot/dto/SpotReadResponse.java new file mode 100644 index 00000000..761b0228 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/SpotReadResponse.java @@ -0,0 +1,25 @@ +package sevenstar.marineleisure.spot.dto; + +import java.util.List; + +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.TotalIndex; + +public record SpotReadResponse( + List spots +) { + public record SpotInfo( + Long id, + String name, + ActivityCategory category, + Float latitude, + Float longitude, + Float distance, + TotalIndex totalIndex, + Integer monthView, + Integer weekView, + boolean isFavorite + ) { + + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/SpotDetailReadResponse.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/SpotDetailReadResponse.java new file mode 100644 index 00000000..582dbcdf --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/SpotDetailReadResponse.java @@ -0,0 +1,17 @@ +package sevenstar.marineleisure.spot.dto.detail; + +import java.util.List; + +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivitySpotDetail; + +public record SpotDetailReadResponse( + Long spotId, + String name, + ActivityCategory category, + float latitude, + float longitude, + boolean isFavorite, + List detail +) { +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/FishDetail.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/FishDetail.java new file mode 100644 index 00000000..684eb7f9 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/FishDetail.java @@ -0,0 +1,8 @@ +package sevenstar.marineleisure.spot.dto.detail.items; + +public record FishDetail( + Long id, + String name +) { + +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/FishingSpotDetail.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/FishingSpotDetail.java new file mode 100644 index 00000000..97358fe2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/FishingSpotDetail.java @@ -0,0 +1,54 @@ +package sevenstar.marineleisure.spot.dto.detail.items; + +import java.time.LocalDate; + +import lombok.Getter; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivitySpotDetail; +import sevenstar.marineleisure.spot.dto.projection.FishingReadProjection; + +@Getter +public class FishingSpotDetail implements ActivitySpotDetail { + + private LocalDate forecastDate; + private TimePeriod timePeriod; + private String tide; + private TotalIndex totalIndex; + private RangeDetail waveHeight; + private RangeDetail seaTemp; + private RangeDetail airTemp; + private RangeDetail currentSpeed; + private RangeDetail windSpeed; + private int uvIndex; + private FishDetail target; + + private FishingSpotDetail(LocalDate forecastDate, TimePeriod timePeriod, String tide, TotalIndex totalIndex, + RangeDetail waveHeight, RangeDetail seaTemp, RangeDetail airTemp, RangeDetail currentSpeed, + RangeDetail windSpeed, + int uvIndex, FishDetail target) { + this.forecastDate = forecastDate; + this.timePeriod = timePeriod; + this.tide = tide; + this.totalIndex = totalIndex; + this.waveHeight = waveHeight; + this.seaTemp = seaTemp; + this.airTemp = airTemp; + this.currentSpeed = currentSpeed; + this.windSpeed = windSpeed; + this.uvIndex = uvIndex; + this.target = target; + } + + public static FishingSpotDetail of(FishingReadProjection projection) { + return new FishingSpotDetail(projection.getForecastDate(), projection.getTimePeriod(), + projection.getTide().getDescription(), + projection.getTotalIndex(), + RangeDetail.of(projection.getWaveHeightMin(), projection.getWaveHeightMax()), + RangeDetail.of(projection.getSeaTempMin(), projection.getSeaTempMax()), + RangeDetail.of(projection.getAirTempMin(), projection.getAirTempMax()), + RangeDetail.of(projection.getCurrentSpeedMin(), projection.getCurrentSpeedMax()), + RangeDetail.of(projection.getWindSpeedMin(), projection.getWindSpeedMax()), + projection.getUvIndex().intValue(), new FishDetail(projection.getTargetId(), projection.getTargetName())); + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/MudflatSpotDetail.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/MudflatSpotDetail.java new file mode 100644 index 00000000..28d1f7e2 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/MudflatSpotDetail.java @@ -0,0 +1,41 @@ +package sevenstar.marineleisure.spot.dto.detail.items; + +import java.time.LocalDate; + +import lombok.Getter; +import sevenstar.marineleisure.forecast.domain.Mudflat; +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.global.utils.DateUtils; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivitySpotDetail; + +@Getter +public class MudflatSpotDetail implements ActivitySpotDetail { + private final LocalDate forecastDate; + private final String startTime; + private final String endTime; + private final RangeDetail airTemp; + private final RangeDetail windSpeed; + private final String weather; + private final TotalIndex totalIndex; + private final int uvIndex; + + private MudflatSpotDetail(LocalDate forecastDate, String startTime, String endTime, RangeDetail airTemp, + RangeDetail windSpeed, String weather, TotalIndex totalIndex, int uvIndex) { + this.forecastDate = forecastDate; + this.startTime = startTime; + this.endTime = endTime; + this.airTemp = airTemp; + this.windSpeed = windSpeed; + this.weather = weather; + this.totalIndex = totalIndex; + this.uvIndex = uvIndex; + } + + public static MudflatSpotDetail of(Mudflat mudflatForecast) { + return new MudflatSpotDetail(mudflatForecast.getForecastDate(), + DateUtils.formatTime(mudflatForecast.getStartTime()), DateUtils.formatTime(mudflatForecast.getEndTime()), + RangeDetail.of(mudflatForecast.getAirTempMin(), mudflatForecast.getAirTempMax()), + RangeDetail.of(mudflatForecast.getWindSpeedMin(), mudflatForecast.getWindSpeedMax()), + mudflatForecast.getWeather(), mudflatForecast.getTotalIndex(), mudflatForecast.getUvIndex().intValue()); + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/RangeDetail.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/RangeDetail.java new file mode 100644 index 00000000..f81a5fc8 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/RangeDetail.java @@ -0,0 +1,10 @@ +package sevenstar.marineleisure.spot.dto.detail.items; + +public record RangeDetail( + float min, + float max +) { + public static RangeDetail of(float min, float max) { + return new RangeDetail(min, max); + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/ScubaSpotDetail.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/ScubaSpotDetail.java new file mode 100644 index 00000000..39fc4f32 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/ScubaSpotDetail.java @@ -0,0 +1,45 @@ +package sevenstar.marineleisure.spot.dto.detail.items; + +import java.time.LocalDate; + +import sevenstar.marineleisure.forecast.domain.Scuba; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.global.utils.DateUtils; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivitySpotDetail; + +public class ScubaSpotDetail implements ActivitySpotDetail { + private final LocalDate forecastDate; + private final TimePeriod timePeriod; + private final String sunrise; + private final String sunset; + private final String tide; + private final RangeDetail waveHeight; + private final RangeDetail seaTemp; + private final RangeDetail currentSpeed; + private final TotalIndex totalIndex; + + private ScubaSpotDetail(LocalDate forecastDate, TimePeriod timePeriod, String sunrise, String sunset, String tide, + RangeDetail waveHeight, RangeDetail seaTemp, RangeDetail currentSpeed, TotalIndex totalIndex) { + this.forecastDate = forecastDate; + this.timePeriod = timePeriod; + this.sunrise = sunrise; + this.sunset = sunset; + this.tide = tide; + this.waveHeight = waveHeight; + this.seaTemp = seaTemp; + this.currentSpeed = currentSpeed; + this.totalIndex = totalIndex; + } + + public static ScubaSpotDetail of(Scuba scubaForecast) { + return new ScubaSpotDetail(scubaForecast.getForecastDate(), scubaForecast.getTimePeriod(), + DateUtils.formatTime(scubaForecast.getSunrise()), DateUtils.formatTime(scubaForecast.getSunset()), + scubaForecast.getTide().getDescription(), + RangeDetail.of(scubaForecast.getWaveHeightMin(), scubaForecast.getWaveHeightMax()), + RangeDetail.of(scubaForecast.getSeaTempMin(), scubaForecast.getSeaTempMax()), + RangeDetail.of(scubaForecast.getCurrentSpeedMin(), scubaForecast.getCurrentSpeedMax()), + scubaForecast.getTotalIndex()); + + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/SurfingSpotDetail.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/SurfingSpotDetail.java new file mode 100644 index 00000000..ad395820 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/items/SurfingSpotDetail.java @@ -0,0 +1,39 @@ +package sevenstar.marineleisure.spot.dto.detail.items; + +import java.time.LocalDate; + +import lombok.Getter; +import sevenstar.marineleisure.forecast.domain.Surfing; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivitySpotDetail; + +@Getter +public class SurfingSpotDetail implements ActivitySpotDetail { + private final LocalDate forecastDate; + private final TimePeriod timePeriod; + private final float waveHeight; + private final int wavePeriod; + private final float windSpeed; + private final float seaTemp; + private final TotalIndex totalIndex; + private final int uvIndex; + + private SurfingSpotDetail(LocalDate forecastDate, TimePeriod timePeriod, float waveHeight, int wavePeriod, + float windSpeed, float seaTemp, TotalIndex totalIndex, int uvIndex) { + this.forecastDate = forecastDate; + this.timePeriod = timePeriod; + this.waveHeight = waveHeight; + this.wavePeriod = wavePeriod; + this.windSpeed = windSpeed; + this.seaTemp = seaTemp; + this.totalIndex = totalIndex; + this.uvIndex = uvIndex; + } + + public static SurfingSpotDetail of(Surfing surfingForecast) { + return new SurfingSpotDetail(surfingForecast.getForecastDate(), surfingForecast.getTimePeriod(), + surfingForecast.getWaveHeight(), surfingForecast.getWavePeriod().intValue(), surfingForecast.getWindSpeed(), + surfingForecast.getSeaTemp(), surfingForecast.getTotalIndex(), surfingForecast.getUvIndex().intValue()); + } +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityDetailProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityDetailProvider.java new file mode 100644 index 00000000..2fe387c3 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityDetailProvider.java @@ -0,0 +1,15 @@ +package sevenstar.marineleisure.spot.dto.detail.provider; + +import java.time.LocalDate; +import java.util.List; + +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.spot.repository.ActivityRepository; + +public interface ActivityDetailProvider { + ActivityCategory getSupportCategory(); + + ActivityRepository getSupportRepository(); + + List getDetails(Long spotId, LocalDate date); +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityDetailProviderFactory.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityDetailProviderFactory.java new file mode 100644 index 00000000..8bf75d2b --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivityDetailProviderFactory.java @@ -0,0 +1,31 @@ +package sevenstar.marineleisure.spot.dto.detail.provider; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import sevenstar.marineleisure.global.enums.ActivityCategory; + +@Component +public class ActivityDetailProviderFactory { + private final Map providers = new EnumMap<>(ActivityCategory.class); + private final List detailProviders; + + public ActivityDetailProviderFactory(List detailProviders) { + this.detailProviders = detailProviders; + } + + @PostConstruct + public void init() { + for (ActivityDetailProvider detailProvider : detailProviders) { + providers.put(detailProvider.getSupportCategory(), detailProvider); + } + } + + public ActivityDetailProvider getProvider(ActivityCategory category) { + return providers.get(category); + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivitySpotDetail.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivitySpotDetail.java new file mode 100644 index 00000000..4fdfa908 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ActivitySpotDetail.java @@ -0,0 +1,4 @@ +package sevenstar.marineleisure.spot.dto.detail.provider; + +public interface ActivitySpotDetail { +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/FishingDetailProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/FishingDetailProvider.java new file mode 100644 index 00000000..8c963983 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/FishingDetailProvider.java @@ -0,0 +1,45 @@ +package sevenstar.marineleisure.spot.dto.detail.provider; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.forecast.repository.FishingRepository; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.spot.dto.detail.items.FishingSpotDetail; +import sevenstar.marineleisure.spot.dto.projection.FishingReadProjection; +import sevenstar.marineleisure.spot.repository.ActivityRepository; + +@Component +@RequiredArgsConstructor +public class FishingDetailProvider implements ActivityDetailProvider { + private final FishingRepository fishingRepository; + + @Override + public ActivityCategory getSupportCategory() { + return ActivityCategory.FISHING; + } + + @Override + public ActivityRepository getSupportRepository() { + return fishingRepository; + } + + @Override + public List getDetails(Long spotId, LocalDate date) { + List fishingForecasts = fishingRepository.findForecastsWithFish(spotId, date); + return transform(fishingForecasts); + } + + private List transform(List fishingForecasts) { + List details = new ArrayList<>(); + for (FishingReadProjection fishingForecast : fishingForecasts) { + details.add(FishingSpotDetail.of(fishingForecast)); + } + return details; + } + +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/MudflatDetailProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/MudflatDetailProvider.java new file mode 100644 index 00000000..5e4c052f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/MudflatDetailProvider.java @@ -0,0 +1,43 @@ +package sevenstar.marineleisure.spot.dto.detail.provider; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.forecast.domain.Mudflat; +import sevenstar.marineleisure.forecast.repository.MudflatRepository; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.spot.dto.detail.items.MudflatSpotDetail; +import sevenstar.marineleisure.spot.repository.ActivityRepository; + +@Component +@RequiredArgsConstructor +public class MudflatDetailProvider implements ActivityDetailProvider { + private final MudflatRepository mudflatRepository; + + @Override + public ActivityCategory getSupportCategory() { + return ActivityCategory.MUDFLAT; + } + + @Override + public ActivityRepository getSupportRepository() { + return mudflatRepository; + } + + @Override + public List getDetails(Long spotId, LocalDate date) { + return transform(mudflatRepository.findForecasts(spotId, date)); + } + + private List transform(List mudflatForecasts) { + List details = new ArrayList<>(); + for (Mudflat mudflatForecast : mudflatForecasts) { + details.add(MudflatSpotDetail.of(mudflatForecast)); + } + return details; + } +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ScubaDetailProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ScubaDetailProvider.java new file mode 100644 index 00000000..343dddbd --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/ScubaDetailProvider.java @@ -0,0 +1,44 @@ +package sevenstar.marineleisure.spot.dto.detail.provider; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.forecast.domain.Scuba; +import sevenstar.marineleisure.forecast.repository.ScubaRepository; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.spot.dto.detail.items.ScubaSpotDetail; +import sevenstar.marineleisure.spot.repository.ActivityRepository; + +@Component +@RequiredArgsConstructor +public class ScubaDetailProvider implements ActivityDetailProvider { + private final ScubaRepository scubaRepository; + + @Override + public ActivityCategory getSupportCategory() { + return ActivityCategory.SCUBA; + } + + @Override + public ActivityRepository getSupportRepository() { + return scubaRepository; + } + + @Override + public List getDetails(Long spotId, LocalDate date) { + return transform(scubaRepository.findForecasts(spotId, date)); + } + + private List transform(List scubaForecasts) { + List details = new ArrayList<>(); + for (Scuba scubaForecast : scubaForecasts) { + details.add(ScubaSpotDetail.of(scubaForecast)); + } + return details; + } + +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/SurfingDetailProvider.java b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/SurfingDetailProvider.java new file mode 100644 index 00000000..c63f3c6e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/detail/provider/SurfingDetailProvider.java @@ -0,0 +1,44 @@ +package sevenstar.marineleisure.spot.dto.detail.provider; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.forecast.domain.Surfing; +import sevenstar.marineleisure.forecast.repository.SurfingRepository; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.spot.dto.detail.items.SurfingSpotDetail; +import sevenstar.marineleisure.spot.repository.ActivityRepository; + +@Component +@RequiredArgsConstructor +public class SurfingDetailProvider implements ActivityDetailProvider { + private final SurfingRepository surfingRepository; + + @Override + public ActivityCategory getSupportCategory() { + return ActivityCategory.SURFING; + } + + @Override + public ActivityRepository getSupportRepository() { + return surfingRepository; + } + + @Override + public List getDetails(Long spotId, LocalDate date) { + return transform(surfingRepository.findForecasts(spotId, date)); + } + + private List transform(List surfingForecasts) { + List details = new ArrayList<>(); + for (Surfing surfingForecast : surfingForecasts) { + details.add(SurfingSpotDetail.of(surfingForecast)); + } + return details; + } + +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/projection/FishingReadProjection.java b/src/main/java/sevenstar/marineleisure/spot/dto/projection/FishingReadProjection.java new file mode 100644 index 00000000..73db83a6 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/projection/FishingReadProjection.java @@ -0,0 +1,27 @@ +package sevenstar.marineleisure.spot.dto.projection; + +import java.time.LocalDate; + +import sevenstar.marineleisure.global.enums.TidePhase; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; + +public interface FishingReadProjection { + LocalDate getForecastDate(); + TimePeriod getTimePeriod(); + TidePhase getTide(); + TotalIndex getTotalIndex(); + Float getWaveHeightMin(); + Float getWaveHeightMax(); + Float getSeaTempMin(); + Float getSeaTempMax(); + Float getAirTempMin(); + Float getAirTempMax(); + Float getCurrentSpeedMin(); + Float getCurrentSpeedMax(); + Float getWindSpeedMin(); + Float getWindSpeedMax(); + Float getUvIndex(); + Long getTargetId(); + String getTargetName(); +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/projection/SpotDistanceProjection.java b/src/main/java/sevenstar/marineleisure/spot/dto/projection/SpotDistanceProjection.java new file mode 100644 index 00000000..98040503 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/projection/SpotDistanceProjection.java @@ -0,0 +1,12 @@ +package sevenstar.marineleisure.spot.dto.projection; + +import java.math.BigDecimal; + +public interface SpotDistanceProjection { + Long getId(); + String getName(); + String getCategory(); + BigDecimal getLatitude(); + BigDecimal getLongitude(); + Double getDistance(); +} diff --git a/src/main/java/sevenstar/marineleisure/spot/dto/projection/SpotPreviewProjection.java b/src/main/java/sevenstar/marineleisure/spot/dto/projection/SpotPreviewProjection.java new file mode 100644 index 00000000..3ea4a291 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/dto/projection/SpotPreviewProjection.java @@ -0,0 +1,9 @@ +package sevenstar.marineleisure.spot.dto.projection; + +import sevenstar.marineleisure.global.enums.TotalIndex; + +public interface SpotPreviewProjection { + Long getSpotId(); + String getName(); + TotalIndex getTotalIndex(); +} diff --git a/src/main/java/sevenstar/marineleisure/spot/mapper/SpotMapper.java b/src/main/java/sevenstar/marineleisure/spot/mapper/SpotMapper.java new file mode 100644 index 00000000..5972e71c --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/mapper/SpotMapper.java @@ -0,0 +1,30 @@ +package sevenstar.marineleisure.spot.mapper; + +import java.util.List; + +import lombok.experimental.UtilityClass; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.domain.SpotViewQuartile; +import sevenstar.marineleisure.spot.dto.SpotReadResponse; +import sevenstar.marineleisure.spot.dto.detail.SpotDetailReadResponse; +import sevenstar.marineleisure.spot.dto.projection.SpotDistanceProjection; + +@UtilityClass +public class SpotMapper { + public static SpotReadResponse.SpotInfo toDto(SpotDistanceProjection spotDistanceProjection, TotalIndex totalIndex, + SpotViewQuartile spotViewQuartile, boolean isFavorite) { + return new SpotReadResponse.SpotInfo(spotDistanceProjection.getId(), spotDistanceProjection.getName(), + ActivityCategory.parse(spotDistanceProjection.getCategory()), + spotDistanceProjection.getLatitude().floatValue(), spotDistanceProjection.getLongitude().floatValue(), + spotDistanceProjection.getDistance().floatValue(), totalIndex, spotViewQuartile.getMonthQuartile(), + spotViewQuartile.getWeekQuartile(), isFavorite); + } + + public static SpotDetailReadResponse toDto(OutdoorSpot outdoorSpot, boolean isFavorite, List detail) { + return new SpotDetailReadResponse(outdoorSpot.getId(), outdoorSpot.getName(), outdoorSpot.getCategory(), + outdoorSpot.getLatitude().floatValue(), outdoorSpot.getLongitude().floatValue(), isFavorite, detail); + } +} + diff --git a/src/main/java/sevenstar/marineleisure/spot/repository/ActivityRepository.java b/src/main/java/sevenstar/marineleisure/spot/repository/ActivityRepository.java new file mode 100644 index 00000000..495a5e4b --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/repository/ActivityRepository.java @@ -0,0 +1,32 @@ +package sevenstar.marineleisure.spot.repository; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.data.repository.query.Param; + +import sevenstar.marineleisure.global.enums.TotalIndex; + +@NoRepositoryBean +public interface ActivityRepository extends JpaRepository { + @Query(""" + SELECT e.totalIndex + FROM #{#entityName} e + WHERE e.spotId = :spotId AND e.forecastDate = :date + """) + Slice findTotalIndex(@Param("spotId") Long spotId, @Param("date") LocalDate date, Pageable pageable); + + @Query(""" + SELECT e + FROM #{#entityName} e + WHERE e.id = :spotId + AND e.forecastDate = :date + """) + List findForecasts(@Param("spotId") Long spotId, @Param("date") LocalDate date); + +} diff --git a/src/main/java/sevenstar/marineleisure/spot/repository/OutdoorSpotRepository.java b/src/main/java/sevenstar/marineleisure/spot/repository/OutdoorSpotRepository.java new file mode 100644 index 00000000..463a6733 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/repository/OutdoorSpotRepository.java @@ -0,0 +1,127 @@ +package sevenstar.marineleisure.spot.repository; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.dto.projection.SpotDistanceProjection; +import sevenstar.marineleisure.spot.dto.projection.SpotPreviewProjection; + +public interface OutdoorSpotRepository extends JpaRepository { + Optional findByLatitudeAndLongitudeAndCategory(BigDecimal latitude, BigDecimal longitude, + ActivityCategory category); + + @Query(value = """ + SELECT o.id, o.name, o.category,o.latitude,o.longitude,ST_Distance_Sphere(o.geo_point, ST_SRID(POINT(:longitude, :latitude),4326)) AS distance + FROM outdoor_spots o + WHERE ST_Distance_Sphere(o.geo_point, ST_SRID(POINT(:longitude, :latitude),4326)) <= :radius + AND (:category IS NULL OR o.category = :category) + """, nativeQuery = true) + List findSpots(@Param("latitude") Float latitude, + @Param("longitude") Float longitude, @Param("radius") double radius, @Param("category") String category); + + // TODO : 리팩토링 무조건 필요 (지점 기반 프리셋 생성후 프리뷰같은) + @Query(value = """ + SELECT + os.id AS spotId, + os.name AS name, + f.total_index AS totalIndex + FROM outdoor_spots os + JOIN fishing_forecast f ON os.id = f.spot_id + WHERE f.forecast_date = :forecastDate + ORDER BY + CASE f.total_index + WHEN 'IMPOSSIBLE' THEN -1 + WHEN 'VERY_BAD' THEN (1.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'BAD' THEN (2.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'NORMAL' THEN (3.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'GOOD' THEN (4.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'VERY_GOOD' THEN (5.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + END DESC + LIMIT 1 + """, nativeQuery = true) + SpotPreviewProjection findBestSpotInFishing(@Param("latitude") double latitude, + @Param("longitude") double longitude, @Param("forecastDate") LocalDate forecastDate); + + @Query(value = """ + SELECT + os.id AS spotId, + os.name AS name, + m.total_index AS totalIndex + FROM outdoor_spots os + JOIN mudflat_forecast m ON os.id = m.spot_id + WHERE m.forecast_date = :forecastDate + ORDER BY + CASE m.total_index + WHEN 'IMPOSSIBLE' THEN -1 + WHEN 'VERY_BAD' THEN (1.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'BAD' THEN (2.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'NORMAL' THEN (3.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'GOOD' THEN (4.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'VERY_GOOD' THEN (5.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + END DESC + LIMIT 1 + """, nativeQuery = true) + SpotPreviewProjection findBestSpotInMudflat(@Param("latitude") double latitude, + @Param("longitude") double longitude, @Param("forecastDate") LocalDate forecastDate); + + @Query(value = """ + SELECT + os.id AS spotId, + os.name AS name, + s.total_index AS totalIndex + FROM outdoor_spots os + JOIN surfing_forecast s ON os.id = s.spot_id + WHERE s.forecast_date = :forecastDate + ORDER BY + CASE s.total_index + WHEN 'IMPOSSIBLE' THEN -1 + WHEN 'VERY_BAD' THEN (1.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'BAD' THEN (2.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'NORMAL' THEN (3.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'GOOD' THEN (4.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'VERY_GOOD' THEN (5.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + END DESC + LIMIT 1 + """, nativeQuery = true) + SpotPreviewProjection findBestSpotInSurfing(@Param("latitude") double latitude, + @Param("longitude") double longitude, @Param("forecastDate") LocalDate forecastDate); + + @Query(value = """ + SELECT + os.id AS spotId, + os.name AS name, + s.total_index AS totalIndex + FROM outdoor_spots os + JOIN scuba_forecast s ON os.id = s.spot_id + WHERE s.forecast_date = :forecastDate + ORDER BY + CASE s.total_index + WHEN 'IMPOSSIBLE' THEN -1 + WHEN 'VERY_BAD' THEN (1.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'BAD' THEN (2.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'NORMAL' THEN (3.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'GOOD' THEN (4.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + WHEN 'VERY_GOOD' THEN (5.0 / 5 + 1 / (ST_Distance_Sphere(os.geo_point, ST_SRID(POINT(:longitude, :latitude), 4326)) / 1000 + 1)) / 2 + END DESC + LIMIT 1 + """, nativeQuery = true) + SpotPreviewProjection findBestSpotInScuba(@Param("latitude") double latitude, @Param("longitude") double longitude, + @Param("forecastDate") LocalDate forecastDate); + + @Query(value = + "SELECT *, ST_Distance_Sphere(POINT(longitude, latitude), POINT(:longitude, :latitude)) as distance_in_meters " + + "FROM outdoor_spot " + + "ORDER BY distance_in_meters ASC " + + "LIMIT :limit" + , nativeQuery = true) + List findByCoordinates(BigDecimal latitude, BigDecimal longitude, int limit); + +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/repository/SpotScoreRepository.java b/src/main/java/sevenstar/marineleisure/spot/repository/SpotScoreRepository.java new file mode 100644 index 00000000..f2681f8f --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/repository/SpotScoreRepository.java @@ -0,0 +1,8 @@ +package sevenstar.marineleisure.spot.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import sevenstar.marineleisure.spot.domain.SpotScore; + +public interface SpotScoreRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/repository/SpotViewQuartileRepository.java b/src/main/java/sevenstar/marineleisure/spot/repository/SpotViewQuartileRepository.java new file mode 100644 index 00000000..b6d138c8 --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/repository/SpotViewQuartileRepository.java @@ -0,0 +1,35 @@ +package sevenstar.marineleisure.spot.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import jakarta.transaction.Transactional; +import sevenstar.marineleisure.spot.domain.SpotViewQuartile; + +public interface SpotViewQuartileRepository extends JpaRepository { + Optional findBySpotId(Long spotId); + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO spot_view_quartile (spot_id, month_quartile, week_quartile, updated_at) + SELECT + spot_id, + NTILE(4) OVER (ORDER BY SUM(view_count)) AS month_quartile, + NTILE(4) OVER ( + ORDER BY SUM(CASE WHEN view_date >= CURDATE() - INTERVAL 7 DAY THEN view_count ELSE 0 END) + ) AS week_quartile, + CURDATE() + FROM spot_view_stats + WHERE view_date >= CURDATE() - INTERVAL 30 DAY + GROUP BY spot_id + ON DUPLICATE KEY UPDATE + month_quartile = VALUES(month_quartile), + week_quartile = VALUES(week_quartile), + updated_at = CURDATE() + """, nativeQuery = true) + void upsertQuartile(); +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/repository/SpotViewStatsRepository.java b/src/main/java/sevenstar/marineleisure/spot/repository/SpotViewStatsRepository.java new file mode 100644 index 00000000..3cfe3f8e --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/repository/SpotViewStatsRepository.java @@ -0,0 +1,22 @@ +package sevenstar.marineleisure.spot.repository; + +import java.time.LocalDate; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import sevenstar.marineleisure.spot.domain.SpotViewStats; +import sevenstar.marineleisure.spot.domain.SpotViewStatsId; + +public interface SpotViewStatsRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO spot_view_stats (spot_id, view_date, view_count) + VALUES (:spotId,:viewDate,1) ON DUPLICATE KEY UPDATE view_count = view_count + 1 + """, nativeQuery = true) + void upsertViewStats(@Param("spotId") Long spotId, @Param("viewDate") LocalDate viewDate); + +} \ No newline at end of file diff --git a/src/main/java/sevenstar/marineleisure/spot/service/SpotService.java b/src/main/java/sevenstar/marineleisure/spot/service/SpotService.java new file mode 100644 index 00000000..3bcb581a --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/service/SpotService.java @@ -0,0 +1,17 @@ +package sevenstar.marineleisure.spot.service; + +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.spot.dto.detail.SpotDetailReadResponse; +import sevenstar.marineleisure.spot.dto.SpotPreviewReadResponse; +import sevenstar.marineleisure.spot.dto.SpotReadResponse; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivitySpotDetail; + +public interface SpotService { + SpotReadResponse searchSpot(float latitude, float longitude, Integer radius, ActivityCategory category); + + SpotDetailReadResponse searchSpotDetail(Long spotId); + + SpotPreviewReadResponse preview(float latitude, float longitude); + + void upsertSpotViewStats(Long spotId); +} diff --git a/src/main/java/sevenstar/marineleisure/spot/service/SpotServiceImpl.java b/src/main/java/sevenstar/marineleisure/spot/service/SpotServiceImpl.java new file mode 100644 index 00000000..9df77cdd --- /dev/null +++ b/src/main/java/sevenstar/marineleisure/spot/service/SpotServiceImpl.java @@ -0,0 +1,125 @@ +package sevenstar.marineleisure.spot.service; + +import static sevenstar.marineleisure.global.api.scheduler.SchedulerService.*; +import static sevenstar.marineleisure.global.util.CurrentUserUtil.*; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sevenstar.marineleisure.favorite.repository.FavoriteRepository; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.SpotErrorCode; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.domain.SpotViewQuartile; +import sevenstar.marineleisure.spot.dto.SpotPreviewReadResponse; +import sevenstar.marineleisure.spot.dto.SpotReadResponse; +import sevenstar.marineleisure.spot.dto.detail.SpotDetailReadResponse; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivityDetailProviderFactory; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivitySpotDetail; +import sevenstar.marineleisure.spot.dto.projection.SpotDistanceProjection; +import sevenstar.marineleisure.spot.dto.projection.SpotPreviewProjection; +import sevenstar.marineleisure.spot.mapper.SpotMapper; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; +import sevenstar.marineleisure.spot.repository.SpotViewQuartileRepository; +import sevenstar.marineleisure.spot.repository.SpotViewStatsRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SpotServiceImpl implements SpotService { + private final OutdoorSpotRepository outdoorSpotRepository; + private final SpotViewStatsRepository spotViewStatsRepository; + private final SpotViewQuartileRepository spotViewQuartileRepository; + private final FavoriteRepository favoriteRepository; + private final ActivityDetailProviderFactory activityDetailProviderFactory; + + @Override + public SpotReadResponse searchSpot(float latitude, float longitude, Integer radius, ActivityCategory category) { + return search(outdoorSpotRepository.findSpots(latitude, longitude, radius * 1000, + category != null ? category.name() : null)); + } + + private SpotReadResponse search(List spotDistanceProjections) { + List infos = new ArrayList<>(); + LocalDate now = LocalDate.now(); + + for (SpotDistanceProjection spotDistanceProjection : spotDistanceProjections) { + TotalIndex totalIndex = getTotalIndex(spotDistanceProjection.getId(), now, + ActivityCategory.parse(spotDistanceProjection.getCategory())); + SpotViewQuartile spotViewQuartile = spotViewQuartileRepository.findBySpotId(spotDistanceProjection.getId()) + .orElseGet(() -> new SpotViewQuartile(1, 1)); + boolean isFavorite = checkFavoriteSpot(spotDistanceProjection.getId()); + + infos.add(SpotMapper.toDto(spotDistanceProjection, totalIndex, spotViewQuartile, isFavorite)); + } + + return new SpotReadResponse(infos); + } + + private TotalIndex getTotalIndex(Long spotId, LocalDate date, ActivityCategory category) { + List totalIndexes = activityDetailProviderFactory.getProvider(category) + .getSupportRepository() + .findTotalIndex(spotId, date, Pageable.ofSize(1)) + .getContent(); + return totalIndexes.stream().findFirst().orElse(TotalIndex.NONE); + } + + @Override + public SpotDetailReadResponse searchSpotDetail(Long spotId) { + OutdoorSpot outdoorSpot = outdoorSpotRepository.findById(spotId) + .orElseThrow(() -> new CustomException(SpotErrorCode.SPOT_NOT_FOUND)); + LocalDate now = LocalDate.now(); + + boolean isFavorite = checkFavoriteSpot(spotId); + + return SpotMapper.toDto(outdoorSpot, isFavorite, + getActivityDetail(outdoorSpot, now, now.plusDays(MAX_UPDATE_DAY))); + } + + private List getActivityDetail(OutdoorSpot outdoorSpot, LocalDate startDate, LocalDate endDate) { + List result = new ArrayList<>(); + for (LocalDate date = startDate; date.isBefore(endDate); date = date.plusDays(1)) { + result.addAll(activityDetailProviderFactory.getProvider(outdoorSpot.getCategory()) + .getDetails(outdoorSpot.getId(), date)); + } + return result; + } + + private boolean checkFavoriteSpot(Long spotId) { + try { + return favoriteRepository.existsByMemberIdAndSpotId(getCurrentUserId(), spotId); + } catch (CustomException e) { + return false; + } + } + + @Override + public SpotPreviewReadResponse preview(float latitude, float longitude) { + LocalDate now = LocalDate.now(); + // TODO : 기능 고도화 필요 + SpotPreviewProjection bestSpotInFishing = outdoorSpotRepository.findBestSpotInFishing(latitude, longitude, now); + SpotPreviewProjection bestSpotInMudflat = outdoorSpotRepository.findBestSpotInMudflat(latitude, longitude, now); + SpotPreviewProjection bestSpotInScuba = outdoorSpotRepository.findBestSpotInScuba(latitude, longitude, now); + SpotPreviewProjection bestSpotInSurfing = outdoorSpotRepository.findBestSpotInSurfing(latitude, longitude, now); + + return new SpotPreviewReadResponse(SpotPreviewReadResponse.SpotPreview.from(bestSpotInFishing), + SpotPreviewReadResponse.SpotPreview.from(bestSpotInMudflat), + SpotPreviewReadResponse.SpotPreview.from(bestSpotInScuba), + SpotPreviewReadResponse.SpotPreview.from(bestSpotInSurfing)); + } + + @Override + @Transactional + public void upsertSpotViewStats(Long spotId) { + spotViewStatsRepository.upsertViewStats(spotId, LocalDate.now()); + } + +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..b0962753 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,66 @@ +spring: + application: + name: MarineLeisure + + config: + activate: + on-profile: prod + sql: + init: + mode: always + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://db:3306/marine + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + properties: + hibernate: + format_sql: true + show_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: update + defer-datasource-initialization: true + + ai: + openai: + api-key: ${OPENAI_KEY} + chat: + model: gpt-3.5-turbo +api: + # 국립해양조사원(Korea Hydrographic and Oceanographic Agency, KHOA) + khoa: + base-url: https://apis.data.go.kr/1192136 + service-key: ${DATAPORTAL_KEY} + type: json + path: + fishing: /fcstFishing/GetFcstFishingApiService + mudflat: /fcstMudflat/GetFcstMudflatApiService + diving: /fcstSkinScuba/GetFcstSkinScubaApiService + surfing: /fcstSurfing/GetFcstSurfingApiService + openmeteo: + base-url: https://api.open-meteo.com/v1/forecast +badanuri: + api: + key: ${BADANURI_KEY} +kakao: + login: + api_key: ${KAKAO_API_KEY} + client_secret: ${KAKAO_CLIENT_SECRET} + redirect_uri: http://localhost:5173/oauth/kakao/callback + uri: + code: /oauth/authorize + base: https://kauth.kakao.com + +jwt: + secret: ${JWT_SECRET} + access-token-validity-in-seconds: 300 + refresh-token-validity-in-seconds: 86400 # 24시간 + use-cookie: false # 개발 환경에서. 클라이언트 개발 완료 후 쿠키 사용 방식으로 변경. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..b588ec20 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,5 @@ +spring: + application: + name: MarineLeisure + profiles: + active: prod diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 00000000..c0606f01 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,42 @@ +use marine; +INSERT INTO jellyfish_species (name, toxicity, created_at, updated_at) +VALUES ('노무라입깃해파리', 'HIGH', NOW(), NOW()), + ('보름달물해파리', 'LOW', NOW(), NOW()), + ('관해파리류', 'LETHAL', NOW(), NOW()), + ('두빛보름달해파리', 'HIGH', NOW(), NOW()), + ('야광원양해파리', 'HIGH', NOW(), NOW()), + ('유령해파리류', 'HIGH', NOW(), NOW()), + ('커튼원양해파리', 'HIGH', NOW(), NOW()), + ('기수식용해파리', 'LOW', NOW(), NOW()), + ('송곳살파', 'NONE', NOW(), NOW()), + ('큰살파', 'NONE', NOW(), NOW()) +ON DUPLICATE KEY UPDATE toxicity = VALUES(toxicity), + updated_at = NOW(); +INSERT INTO jellyfish_region_density(species, region_name, report_date, density_type, updated_at, created_at) +VALUES (1, '인천', '2025-07-03', 'LOW', NOW(), NOW()), + (1, '경기', '2025-07-03', 'LOW', NOW(), NOW()), + (1, '전남', '2025-07-03', 'LOW', NOW(), NOW()), + (1, '경남', '2025-07-03', 'LOW', NOW(), NOW()), + (1, '부산', '2025-07-03', 'LOW', NOW(), NOW()), + (1, '경북', '2025-07-03', 'LOW', NOW(), NOW()), + (1, '제주', '2025-07-03', 'LOW', NOW(), NOW()), + (2, '경기', '2025-07-03', 'HIGH', NOW(), NOW()), + (2, '전북', '2025-07-03', 'HIGH', NOW(), NOW()), + (2, '전남', '2025-07-03', 'HIGH', NOW(), NOW()), + (2, '경남', '2025-07-03', 'HIGH', NOW(), NOW()), + (2, '부산', '2025-07-03', 'HIGH', NOW(), NOW()), + (2, '울산', '2025-07-03', 'HIGH', NOW(), NOW()), + (2, '경북', '2025-07-03', 'HIGH', NOW(), NOW()), + (2, '제주', '2025-07-03', 'HIGH', NOW(), NOW()), + (2, '인천', '2025-07-03', 'LOW', NOW(), NOW()), + (2, '충남', '2025-07-03', 'LOW', NOW(), NOW()), + (4, '강원', '2025-07-03', 'HIGH', NOW(), NOW()), + (4, '경북', '2025-07-03', 'LOW', NOW(), NOW()), + (5, '제주', '2025-07-03', 'LOW', NOW(), NOW()), + (6, '부산', '2025-07-03', 'LOW', NOW(), NOW()), + (6, '제주', '2025-07-03', 'LOW', NOW(), NOW()), + (7, '경남', '2025-07-03', 'HIGH', NOW(), NOW()), + (7, '전남', '2025-07-03', 'LOW', NOW(), NOW()), + (7, '강원', '2025-07-03', 'LOW', NOW(), NOW()) +ON DUPLICATE KEY UPDATE density_type = VALUES(density_type), + updated_at = NOW(); diff --git a/src/main/resources/kakao-api.http b/src/main/resources/kakao-api.http new file mode 100644 index 00000000..fbf3c33d --- /dev/null +++ b/src/main/resources/kakao-api.http @@ -0,0 +1,39 @@ +### Kakao API HTTP Requests + +### Get Kakao Login URL +### +@baseUrl =http://localhost:8083 +# 1. Get Kakao Login URL (따로 없으면 redirecturi == yml 파일의 정보로 가져옴) +GET {{baseUrl}}/auth/kakao/url +Accept: application/json + +### +# 1-2. Get Kakao Login URL (커스텀 리다이렉트 URI) +GET {{baseUrl}}/auth/kakao/url?redirectUri=https://example.com/oauth/kakao/callback +Accept: application/json + +### +# 3. Process Kakao Login (카카오 로그인 실제 진행) +POST {{baseUrl}}/auth/kakao/code +Content-Type: application/json +Accept: application/json + +{ + "code": "your_authorization_code", + "state": "rawState", + "encryptedState": "encryptedState" +} + +### +# 4. Refresh Token +POST {{baseUrl}}/auth/refresh +Content-Type: application/json +Accept: application/json +Cookie: refresh_token=<> + +### +# 5. Logout +POST {{baseUrl}}/auth/logout +Content-Type: application/json +Accept: application/json +Cookie: refresh_token=<> diff --git a/src/main/resources/kakao-test-jwt.http b/src/main/resources/kakao-test-jwt.http new file mode 100644 index 00000000..5f04f422 --- /dev/null +++ b/src/main/resources/kakao-test-jwt.http @@ -0,0 +1,19 @@ +### Test JWT Token Creation with Kakao Access Token +@baseUrl = http://localhost:8083 +# 테스트용 토큰 셍성기 +# {kakaoAccessToken} 에 테스트용 액세스 토큰을 넣어주면 됩니다. + + +POST {{baseUrl}}/auth/kakao/test-jwt?kakaoAccessToken={kakaoAccessToken} +Accept: application/json + +### How to use: +# 1. 카카오 개발자 사이트에서 토큰 가져오기: +# - Go to https://developers.kakao.com/console/app +# - 내 앱( 멤버 추가 해드려야 할듯합니다) +# - "카카오 로그인" > "테스트" +# - "토큰 발급받기" 눌러서 발급. +# 2. 액세스 토큰 복사 +# 3. {kakaoAccessToken} 복사한거 붙여넣기 +# 4. 이 api 호출 +# 5. 응답에 서비스 내의 access 토큰 들어 있다. diff --git a/src/main/resources/member-api.http b/src/main/resources/member-api.http new file mode 100644 index 00000000..ff8b3b7e --- /dev/null +++ b/src/main/resources/member-api.http @@ -0,0 +1,46 @@ +### Member API HTTP Requests + +### Get Current Member Details +@baseUrl =http://localhost:8083 +@jwtToken = 액세스 토큰입니다. + +GET {{baseUrl}}/members/me +Accept: application/json +Authorization: Bearer {{jwtToken}} + +### Update Member Nickname +PUT {{baseUrl}}/members/me +Content-Type: application/json +Accept: application/json +Authorization: Bearer {{jwtToken}} + +{ + "nickname": "new_nickname" +} + +### Update Member Location +PUT {{baseUrl}}/members/me/location +Content-Type: application/json +Accept: application/json +Authorization: Bearer {{jwtToken}} + +{ + "latitude": 37.5665, + "longitude": 126.9780 +} + +### Update Member Status +PATCH {{baseUrl}}/members/me/status +Content-Type: application/json +Accept: application/json +Authorization: Bearer {{jwtToken}} + +{ + "status": "ACTIVE" +} + +### Delete Member (Soft Delete) +POST {{baseUrl}}/members/delete +Content-Type: application/json +Accept: application/json +Authorization: Bearer {{jwtToken}} diff --git a/src/test/java/sevenstar/marineleisure/AbstractTest.java b/src/test/java/sevenstar/marineleisure/AbstractTest.java new file mode 100644 index 00000000..eafaa350 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/AbstractTest.java @@ -0,0 +1,32 @@ +package sevenstar.marineleisure; + +import org.junit.jupiter.api.BeforeAll; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@SpringBootTest +public abstract class AbstractTest { + @Container + static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void overrideProps(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl); + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName); + } + + @BeforeAll + static void setUp() { + mysqlContainer.start(); + } +} diff --git a/src/test/java/sevenstar/marineleisure/MarineLeisureApplicationTests.java b/src/test/java/sevenstar/marineleisure/MarineLeisureApplicationTests.java new file mode 100644 index 00000000..1b56bbcb --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/MarineLeisureApplicationTests.java @@ -0,0 +1,13 @@ +package sevenstar.marineleisure; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class MarineLeisureApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/sevenstar/marineleisure/alert/controller/AlertControllerTest.java b/src/test/java/sevenstar/marineleisure/alert/controller/AlertControllerTest.java new file mode 100644 index 00000000..ee6e4f91 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/alert/controller/AlertControllerTest.java @@ -0,0 +1,110 @@ +package sevenstar.marineleisure.alert.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.validation.Validator; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import sevenstar.marineleisure.alert.dto.response.JellyfishResponseDto; +import sevenstar.marineleisure.alert.dto.vo.JellyfishDetailVO; +import sevenstar.marineleisure.alert.dto.vo.JellyfishRegionVO; +import sevenstar.marineleisure.alert.dto.vo.JellyfishSpeciesVO; +import sevenstar.marineleisure.alert.mapper.AlertMapper; +import sevenstar.marineleisure.alert.service.JellyfishService; + +@WebMvcTest(AlertController.class) +@AutoConfigureMockMvc(addFilters = false) +class AlertControllerTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private JellyfishService jellyfishService; + + @MockitoBean + private AlertMapper alertMapper; + + @Autowired + private Validator validator; + + @Test + @DisplayName("해파리 경보를 성공적으로 반환합니다.") + void sendAlert_Sucess() throws Exception { + // List items = jellyfishService.search(); + // JellyfishResponseDto result = alertMapper.toResponseDto(items); + // return BaseResponse.success(result); + + //given + JellyfishDetailVO mockVO = new JellyfishDetailVO() { + @Override + public String getSpecies() { + return "노무라입깃해파리"; + } + + @Override + public String getRegion() { + return "부산"; + } + + @Override + public String getDensityType() { + return "LOW"; + } + + @Override + public String getToxicity() { + return "HIGH"; + } + + @Override + public LocalDate getReportDate() { + return LocalDate.of(2025, 7, 10); + } + }; + + List voList = List.of(mockVO); + + JellyfishResponseDto responseDto = new JellyfishResponseDto( + LocalDate.of(2025, 7, 10), + List.of(new JellyfishRegionVO( + "부산", + new JellyfishSpeciesVO( + "노무라입깃해파리", + "강독성", // ToxicityLevel.HIGH.getDescription() + "저밀도" // DensityLevel.LOW.getDescription() + ) + )) + ); + + given(jellyfishService.search()).willReturn(voList); + given(alertMapper.toResponseDto(voList)).willReturn(responseDto); + + //when & then + mockMvc.perform(get("/alerts/jellyfish")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.message").value("Success")) + .andExpect(jsonPath("$.body.reportDate").value("2025-07-10")) + .andExpect(jsonPath("$.body.regions[0].regionName").value("부산")) + .andExpect(jsonPath("$.body.regions[0].species.name").value("노무라입깃해파리")) + .andExpect(jsonPath("$.body.regions[0].species.toxicity").value("강독성")) + .andExpect(jsonPath("$.body.regions[0].species.density").value("저밀도")); + + } +} \ No newline at end of file diff --git a/src/test/java/sevenstar/marineleisure/alert/service/JellyfishServiceTest.java b/src/test/java/sevenstar/marineleisure/alert/service/JellyfishServiceTest.java new file mode 100644 index 00000000..54624ecd --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/alert/service/JellyfishServiceTest.java @@ -0,0 +1,128 @@ +package sevenstar.marineleisure.alert.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.io.File; +import java.io.IOException; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import sevenstar.marineleisure.alert.domain.JellyfishRegionDensity; +import sevenstar.marineleisure.alert.domain.JellyfishSpecies; +import sevenstar.marineleisure.alert.dto.vo.JellyfishDetailVO; +import sevenstar.marineleisure.alert.dto.vo.ParsedJellyfishVO; +import sevenstar.marineleisure.alert.repository.JellyfishRegionDensityRepository; +import sevenstar.marineleisure.alert.repository.JellyfishSpeciesRepository; +import sevenstar.marineleisure.alert.util.JellyfishCrawler; +import sevenstar.marineleisure.alert.util.JellyfishParser; +import sevenstar.marineleisure.global.enums.ToxicityLevel; + +@ExtendWith(MockitoExtension.class) +class JellyfishServiceTest { + @Mock + private JellyfishRegionDensityRepository densityRepository; + + @Mock + private JellyfishSpeciesRepository speciesRepository; + + @Mock + private JellyfishParser parser; + + @Mock + private JellyfishCrawler crawler; + @InjectMocks + private JellyfishService service; + + private File mockFile; + private ParsedJellyfishVO parsedJellyfishVO; + + private String species; + private String regionName; + private String density; + + @BeforeEach + void setUp() { + mockFile = new File("jellyfish_20250703.pdf"); + + parsedJellyfishVO = new ParsedJellyfishVO("보름달물해파리", "부산", "고밀도"); + } + + @Test + @DisplayName("가장 최신 해파리 발생 정보 검색") + void searchLatestReport_success() { + //given + given(densityRepository.findLatestJellyfishDetails()) + .willReturn(List.of(mock(JellyfishDetailVO.class))); + + //when + List result = service.search(); + + //then + assertEquals(1, result.size()); + verify(densityRepository).findLatestJellyfishDetails(); + } + + @Test + @DisplayName("해파리 이름으로 종 검색 - 존재할 경우") + void searchByName_found() { + //given + JellyfishSpecies species = JellyfishSpecies.builder().name("보름달물해파리").build(); + given(speciesRepository.findByName("보름달물해파리")) + .willReturn(Optional.of(species)); + + //when + JellyfishSpecies result = service.searchByName("보름달물해파리"); + + //then + assertNotNull(result); + assertEquals("보름달물해파리", result.getName()); + } + + @Test + @DisplayName("보고서 PDF 크롤링 후 DB 저장 - 기존 종") + void updateLatestReport_existingSpecies() throws IOException { + LocalDate date = LocalDate.of(2025, 7, 3); + + given(crawler.downloadLastedPdf()).willReturn(mockFile); + given(parser.extractDateFromFileName(mockFile.getName())).willReturn(date); + given(parser.parsePdfToJson(mockFile)).willReturn(List.of(parsedJellyfishVO)); + + JellyfishSpecies species = JellyfishSpecies.builder() + .name("보름달물해파리") + .toxicity(ToxicityLevel.NONE) + .build(); + given(speciesRepository.findByName(parsedJellyfishVO.species())).willReturn(Optional.of(species)); + + service.updateLatestReport(); + + verify(densityRepository).save(any(JellyfishRegionDensity.class)); + } + + @Test + @DisplayName("보고서 PDF 크롤링 후 DB 저장 - 신종 등록") + void updateLatestReport_newSpecies() throws IOException { + LocalDate date = LocalDate.of(2025, 7, 3); + + given(crawler.downloadLastedPdf()).willReturn(mockFile); + given(parser.extractDateFromFileName(mockFile.getName())).willReturn(date); + given(parser.parsePdfToJson(mockFile)).willReturn(List.of(parsedJellyfishVO)); + + given(speciesRepository.findByName(parsedJellyfishVO.species())).willReturn(Optional.empty()); + given(speciesRepository.save(any())).willAnswer(invocation -> invocation.getArgument(0)); + + service.updateLatestReport(); + + verify(speciesRepository).save(any(JellyfishSpecies.class)); + verify(densityRepository).save(any(JellyfishRegionDensity.class)); + } +} \ No newline at end of file diff --git a/src/test/java/sevenstar/marineleisure/annotation/CustomDataJpaTest.java b/src/test/java/sevenstar/marineleisure/annotation/CustomDataJpaTest.java new file mode 100644 index 00000000..c26b9cba --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/annotation/CustomDataJpaTest.java @@ -0,0 +1,20 @@ +package sevenstar.marineleisure.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import sevenstar.marineleisure.global.config.JpaAuditingConfig; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@DataJpaTest +@Import(JpaAuditingConfig.class) +public @interface CustomDataJpaTest { +} diff --git a/src/test/java/sevenstar/marineleisure/annotation/H2DataJpaTest.java b/src/test/java/sevenstar/marineleisure/annotation/H2DataJpaTest.java new file mode 100644 index 00000000..69774e66 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/annotation/H2DataJpaTest.java @@ -0,0 +1,17 @@ +package sevenstar.marineleisure.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.test.context.ActiveProfiles; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@CustomDataJpaTest +@ActiveProfiles("test") +public @interface H2DataJpaTest { +} diff --git a/src/test/java/sevenstar/marineleisure/annotation/MysqlDataJpaTest.java b/src/test/java/sevenstar/marineleisure/annotation/MysqlDataJpaTest.java new file mode 100644 index 00000000..1c12bd57 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/annotation/MysqlDataJpaTest.java @@ -0,0 +1,15 @@ +package sevenstar.marineleisure.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@CustomDataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public @interface MysqlDataJpaTest { +} diff --git a/src/test/java/sevenstar/marineleisure/favorite/controller/FavoriteControllerTest.java b/src/test/java/sevenstar/marineleisure/favorite/controller/FavoriteControllerTest.java new file mode 100644 index 00000000..732396e4 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/favorite/controller/FavoriteControllerTest.java @@ -0,0 +1,186 @@ +package sevenstar.marineleisure.favorite.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.validation.Validator; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import sevenstar.marineleisure.favorite.domain.FavoriteSpot; +import sevenstar.marineleisure.favorite.dto.response.FavoritePatchDto; +import sevenstar.marineleisure.favorite.dto.vo.FavoriteItemVO; +import sevenstar.marineleisure.favorite.mapper.FavoriteMapper; +import sevenstar.marineleisure.favorite.service.FavoriteServiceImpl; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.FavoriteErrorCode; + +@WebMvcTest(controllers = FavoriteController.class) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class FavoriteControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private FavoriteServiceImpl favoriteService; + + @MockitoBean + private FavoriteMapper favoriteMapper; + + @Autowired + private Validator validator; + + @Test + @DisplayName("즐겨찾기 추가 - 성공") + void addFavorite_Success() throws Exception { + // given + Long spotId = 1L; + given(favoriteService.createFavorite(spotId)).willReturn(spotId); + + // when & then + mockMvc.perform(post("/favorite/{id}", spotId).contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body").value(spotId)); + } + + @Test + @DisplayName("즐겨찾기 목록 조회 - 성공") + void searchFavorites_Success() throws Exception { + // given + Long cursorId = 0L; + int size = 2; + + List mockItems = List.of(FavoriteItemVO.builder() + .id(1L) + .name("장소1") + .category(ActivityCategory.FISHING) + .location("서울") + .notification(true) + .build(), FavoriteItemVO.builder() + .id(2L) + .name("장소2") + .category(ActivityCategory.FISHING) + .location("부산") + .notification(false) + .build(), FavoriteItemVO.builder() + .id(3L) + .name("장소3") + .category(ActivityCategory.FISHING) + .location("대구") + .notification(true) + .build()); + + given(favoriteService.searchFavorite(cursorId, size)).willReturn(mockItems); + + // when & then + mockMvc.perform( + get("/favorite").param("cursorId", "0").param("size", "2").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.favorites").isArray()) + .andExpect(jsonPath("$.body.favorites.length()").value(2)) + .andExpect(jsonPath("$.body.hasNext").value(true)) + .andExpect(jsonPath("$.body.cursorId").value(0)) + .andExpect(jsonPath("$.body.size").value(2)); + } + + @Test + @DisplayName("즐겨찾기 목록 조회 - 다음 페이지 없음") + void searchFavorites_NoNext() throws Exception { + // given + Long cursorId = 0L; + int size = 3; + + List mockItems = List.of(FavoriteItemVO.builder() + .id(1L) + .name("장소1") + .category(ActivityCategory.FISHING) + .location("서울") + .notification(true) + .build(), FavoriteItemVO.builder() + .id(2L) + .name("장소2") + .category(ActivityCategory.FISHING) + .location("부산") + .notification(false) + .build()); + + given(favoriteService.searchFavorite(cursorId, size)).willReturn(mockItems); + + // when & then + mockMvc.perform( + get("/favorite").param("cursorId", "0").param("size", "3").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.favorites").isArray()) + .andExpect(jsonPath("$.body.favorites.length()").value(2)) + .andExpect(jsonPath("$.body.hasNext").value(false)); + } + + @Test + @DisplayName("즐겨찾기 삭제 - 성공") + void removeFavorites_Success() throws Exception { + // given + Long favoriteId = 1L; + willDoNothing().given(favoriteService).removeFavorite(favoriteId); + + // when & then + mockMvc.perform(delete("/favorite/{id}", favoriteId).contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + + then(favoriteService).should().removeFavorite(favoriteId); + } + + @Test + @DisplayName("즐겨찾기 삭제 - 잘못된 ID") + void removeFavorites_InvalidId_Id() throws Exception { + // given + Long invalidId = -1L; + willThrow(new CustomException(FavoriteErrorCode.INVALID_FAVORITE_PARAMETER)).given(favoriteService) + .removeFavorite(invalidId); + + // when & then + mockMvc.perform(delete("/favorite/{id}", invalidId).contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(FavoriteErrorCode.INVALID_FAVORITE_PARAMETER.getCode())) + .andExpect(jsonPath("$.message").value(FavoriteErrorCode.INVALID_FAVORITE_PARAMETER.getMessage())); + } + + @Test + @DisplayName("즐겨찾기 알림 업데이트 - 성공") + void updateFavorites_Success() throws Exception { + // given + Long favoriteId = 1L; + FavoriteSpot mockFavoriteSpot = FavoriteSpot.builder().memberId(1L).spotId(2L).build(); + FavoritePatchDto mockDto = FavoritePatchDto.builder().favoriteId(favoriteId).notification(true).build(); + + given(favoriteService.updateNotification(favoriteId)).willReturn(mockFavoriteSpot); + given(favoriteMapper.toPatchDto(mockFavoriteSpot)).willReturn(mockDto); + + // when & then + mockMvc.perform(patch("/favorite/{id}", favoriteId).contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.favoriteId").value(favoriteId)) + .andExpect(jsonPath("$.body.notification").value(true)); + } +} \ No newline at end of file diff --git a/src/test/java/sevenstar/marineleisure/favorite/domain/FavoriteSpotTest.java b/src/test/java/sevenstar/marineleisure/favorite/domain/FavoriteSpotTest.java new file mode 100644 index 00000000..b812b074 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/favorite/domain/FavoriteSpotTest.java @@ -0,0 +1,24 @@ +package sevenstar.marineleisure.favorite.domain; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class FavoriteSpotTest { + @Test + @DisplayName("FavoriteSpot의 notification 토글 테스트") + void togglenotification() { + // given + + FavoriteSpot spot = FavoriteSpot.builder() + .spotId(1L) + .memberId(1L) + .build(); + + // when + spot.toggleNotification(); + // then + Assertions.assertFalse(spot.getNotification()); + } + +} \ No newline at end of file diff --git a/src/test/java/sevenstar/marineleisure/favorite/service/FavoriteServiceImplTest.java b/src/test/java/sevenstar/marineleisure/favorite/service/FavoriteServiceImplTest.java new file mode 100644 index 00000000..16973de2 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/favorite/service/FavoriteServiceImplTest.java @@ -0,0 +1,235 @@ +package sevenstar.marineleisure.favorite.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import sevenstar.marineleisure.favorite.domain.FavoriteSpot; +import sevenstar.marineleisure.favorite.dto.vo.FavoriteItemVO; +import sevenstar.marineleisure.favorite.repository.FavoriteRepository; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.FavoriteErrorCode; +import sevenstar.marineleisure.global.util.CurrentUserUtil; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@ExtendWith(MockitoExtension.class) +class FavoriteServiceImplTest { + + @Mock + private FavoriteRepository favoriteRepository; + + @Mock + private OutdoorSpotRepository outdoorSpotRepository; + + @InjectMocks + private FavoriteServiceImpl service; + + private Long currentMemberId; + private Long favorite1Id; + private Long favorite2Id; + private Long spot1Id; + + @BeforeEach + void setUp() { + currentMemberId = 1L; + favorite1Id = 1L; + favorite2Id = 2L; + spot1Id = 1L; + } + + @Test + @DisplayName("즐겨찾기 유효성 검사 - 성공") + void availbleTrue() { + //given + FavoriteSpot fav1 = mock(FavoriteSpot.class); + fav1 = mock(FavoriteSpot.class); + when(fav1.getSpotId()).thenReturn(favorite1Id); + when(fav1.getMemberId()).thenReturn(currentMemberId); + given(favoriteRepository.findById(favorite1Id)).willReturn(Optional.of(fav1)); + + //when + FavoriteSpot result = service.searchFavoriteById(favorite1Id); + + //then + assertNotNull(result); + assertEquals(fav1.getMemberId(), result.getMemberId()); + assertEquals(fav1.getSpotId(), result.getSpotId()); + } + + @Test + @DisplayName("즐겨찾기 유효성 검사 - 실패") + void unavailableFalse() { + //given + given(favoriteRepository.findById(favorite1Id)).willReturn(Optional.empty()); + + //when & then + CustomException exception = assertThrows(CustomException.class, + () -> service.searchFavoriteById(favorite1Id)); + assertEquals(FavoriteErrorCode.FAVORITE_NOT_FOUND, exception.getErrorCode()); + } + + @Test + @DisplayName("즐겨찾기 생성 성공") + void createFavorite_Sucess() { + //given + OutdoorSpot spot1 = mock(OutdoorSpot.class); + FavoriteSpot fav1 = mock(FavoriteSpot.class); + + spot1 = mock(OutdoorSpot.class); + fav1 = mock(FavoriteSpot.class); + + try (MockedStatic mockedStatic = mockStatic(CurrentUserUtil.class)) { + mockedStatic.when(CurrentUserUtil::getCurrentUserId).thenReturn(currentMemberId); + + given(outdoorSpotRepository.findById(spot1Id)) + .willReturn(Optional.of(spot1)); + given(favoriteRepository.save(any(FavoriteSpot.class))) + .willReturn(fav1); + + //when + Long result = service.createFavorite(spot1Id); + + //then + assertEquals(spot1Id, result); + verify(favoriteRepository).save(any(FavoriteSpot.class)); + } + } + + @Test + @DisplayName("즐겨찾기 생성 실패 - 존재하지 않는 스팟") + void createFavorite_SpotNotFound() { + // given + try (MockedStatic mockedStatic = mockStatic(CurrentUserUtil.class)) { + mockedStatic.when(CurrentUserUtil::getCurrentUserId).thenReturn(currentMemberId); + + given(outdoorSpotRepository.findById(spot1Id)) + .willReturn(Optional.empty()); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> service.createFavorite(spot1Id)); + assertEquals(FavoriteErrorCode.FAVORITE_NOT_FOUND, exception.getErrorCode()); + } + } + + @Test + @DisplayName("즐겨찾기 목록 조회 성공") + void searchFavorite_Sucess() { + //given + Long cursorId = 0L; + int size = 1; + try (MockedStatic mockedStatic = mockStatic(CurrentUserUtil.class)) { + mockedStatic.when(CurrentUserUtil::getCurrentUserId).thenReturn(currentMemberId); + + List mockResult = List.of( + FavoriteItemVO.builder().build(), + FavoriteItemVO.builder().build() + ); + + Pageable pageable = PageRequest.of(0, size + 1); + given(favoriteRepository.findFavoritesByMemberIdAndCursorId(currentMemberId, cursorId, pageable)) + .willReturn(mockResult); + + //when + List result = service.searchFavorite(cursorId, size); + + //then + assertNotNull(result); + assertEquals(2, result.size()); + verify(favoriteRepository).findFavoritesByMemberIdAndCursorId(currentMemberId, cursorId, pageable); + } + } + + @Test + @DisplayName("즐겨찾기 삭제 성공") + void removeFavorite_Success() { + // given + FavoriteSpot fav1 = mock(FavoriteSpot.class); + when(fav1.getMemberId()).thenReturn(currentMemberId); + + try (MockedStatic mockedStatic = mockStatic(CurrentUserUtil.class)) { + mockedStatic.when(CurrentUserUtil::getCurrentUserId).thenReturn(currentMemberId); + + given(favoriteRepository.findById(favorite1Id)) + .willReturn(Optional.of(fav1)); + + // when + service.removeFavorite(favorite1Id); + + // then + verify(favoriteRepository).deleteFavoriteSpotById(favorite1Id); + } + } + + @Test + @DisplayName("즐겨찾기 알림 업데이트 성공") + void updateNotification_Success() { + // given + FavoriteSpot fav1 = mock(FavoriteSpot.class); + when(fav1.getMemberId()).thenReturn(currentMemberId); + + try (MockedStatic mockedStatic = mockStatic(CurrentUserUtil.class)) { + mockedStatic.when(CurrentUserUtil::getCurrentUserId).thenReturn(currentMemberId); + + given(favoriteRepository.findById(favorite1Id)) + .willReturn(Optional.of(fav1)); + + // when + FavoriteSpot result = service.updateNotification(favorite1Id); + + // then + assertNotNull(result); + assertEquals(fav1.getMemberId(), result.getMemberId()); + assertEquals(fav1.getSpotId(), result.getSpotId()); + verify(fav1).toggleNotification(); + } + } + + @Test + @DisplayName("즐겨찾기 알림 업데이트 실패 - 존재하지 않는 즐겨찾기") + void updateNotification_NotFound() { + // given + + given(favoriteRepository.findById(favorite1Id)) + .willReturn(Optional.empty()); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> service.updateNotification(favorite1Id)); + assertEquals(FavoriteErrorCode.FAVORITE_NOT_FOUND, exception.getErrorCode()); + } + + @Test + @DisplayName("즐겨찾기 알림 업데이트 실패 - 권한 없음") + void updateNotification_Forbidden() { + // given + FavoriteSpot fav2 = mock(FavoriteSpot.class); + when(fav2.getMemberId()).thenReturn(2L); + + try (MockedStatic mockedStatic = mockStatic(CurrentUserUtil.class)) { + mockedStatic.when(CurrentUserUtil::getCurrentUserId).thenReturn(currentMemberId); + + given(favoriteRepository.findById(favorite2Id)) + .willReturn(Optional.of(fav2)); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> service.updateNotification(favorite2Id)); + assertEquals(FavoriteErrorCode.FORBIDDEN_FAVORITE_ACCESS, exception.getErrorCode()); + } + } +} \ No newline at end of file diff --git a/src/test/java/sevenstar/marineleisure/global/api/ApiClientTest.java b/src/test/java/sevenstar/marineleisure/global/api/ApiClientTest.java new file mode 100644 index 00000000..0114a677 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/global/api/ApiClientTest.java @@ -0,0 +1,101 @@ +package sevenstar.marineleisure.global.api; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import sevenstar.marineleisure.AbstractTest; +import sevenstar.marineleisure.global.api.khoa.KhoaApiClient; +import sevenstar.marineleisure.global.api.khoa.dto.common.ApiResponse; +import sevenstar.marineleisure.global.api.khoa.dto.item.FishingItem; +import sevenstar.marineleisure.global.api.khoa.dto.item.MudflatItem; +import sevenstar.marineleisure.global.api.khoa.dto.item.ScubaItem; +import sevenstar.marineleisure.global.api.khoa.dto.item.SurfingItem; +import sevenstar.marineleisure.global.api.openmeteo.OpenMeteoApiClient; +import sevenstar.marineleisure.global.api.openmeteo.dto.common.OpenMeteoReadResponse; +import sevenstar.marineleisure.global.api.openmeteo.dto.item.SunTimeItem; +import sevenstar.marineleisure.global.api.openmeteo.dto.item.UvIndexItem; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.FishingType; + +/** + * 외부 API 클라이언트 조회 테스트 + */ +// @SpringBootTest +public class ApiClientTest extends AbstractTest { + @Autowired + private KhoaApiClient khoaApiClient; + @Autowired + private OpenMeteoApiClient openMeteoApiClient; + + private LocalDate reqDate = LocalDate.now(); + + @Test + void receiveFishApi() { + ResponseEntity> response = khoaApiClient.get(new ParameterizedTypeReference<>() { + }, reqDate, 1, 15, FishingType.ROCK); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getResponse().getBody().getItems().getItem()).hasSize(15); + } + + @Test + void receiveSurfingApi() { + ResponseEntity> response = khoaApiClient.get(new ParameterizedTypeReference<>() { + }, reqDate, 1, 15, ActivityCategory.SURFING); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getResponse().getBody().getItems().getItem()).hasSize(15); + } + + @Test + void receiveMudflatApi() { + ResponseEntity> response = khoaApiClient.get(new ParameterizedTypeReference<>() { + }, reqDate, 1, 15, ActivityCategory.MUDFLAT); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getResponse().getBody().getItems().getItem()).hasSize(15); + } + + @Test + void receiveDivingApi() { + ResponseEntity> response = khoaApiClient.get(new ParameterizedTypeReference<>() { + }, reqDate, 1, 15, ActivityCategory.SCUBA); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getResponse().getBody().getItems().getItem()).hasSize(15); + } + + @Test + void receiveSunTimes() { + ResponseEntity> result = openMeteoApiClient.getSunTimes( + new ParameterizedTypeReference<>() { + }, LocalDate.now(), LocalDate.now(), 37.526126, 126.922255 + ); + + assertThat(result.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getDaily().getTime().size()).isEqualTo( + result.getBody().getDaily().getSunrise().size()); + assertThat(result.getBody().getDaily().getTime().size()).isEqualTo( + result.getBody().getDaily().getSunset().size()); + assertThat(result.getBody().getDaily()).isNotNull(); + } + + @Test + void receiveUvIndex() { + ResponseEntity> result = openMeteoApiClient.getUvIndex( + new ParameterizedTypeReference<>() { + }, LocalDate.now(), LocalDate.now(), 37.526126, 126.922255 + ); + + assertThat(result.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getDaily().getTime().size()).isEqualTo( + result.getBody().getDaily().getUvIndexMax().size()); + assertThat(result.getBody().getDaily()).isNotNull(); + } +} diff --git a/src/test/java/sevenstar/marineleisure/global/api/ApiServiceIntegrationTest.java b/src/test/java/sevenstar/marineleisure/global/api/ApiServiceIntegrationTest.java new file mode 100644 index 00000000..4f4b3042 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/global/api/ApiServiceIntegrationTest.java @@ -0,0 +1,51 @@ +package sevenstar.marineleisure.global.api; + +import java.time.LocalDate; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; + +import sevenstar.marineleisure.global.api.khoa.service.KhoaApiService; +import sevenstar.marineleisure.global.api.openmeteo.dto.service.OpenMeteoService; +import sevenstar.marineleisure.global.api.scheduler.SchedulerService; + +/** + * 해당 테스트는 실제 API를 호출하여 데이터를 가져오는 통합 테스트입니다. + * 수동으로 확인해보기 위함을 참고 부탁드립니다. + * @author gunwoong + */ +@SpringBootTest +@Disabled +public class ApiServiceIntegrationTest { + @Autowired + private SchedulerService schedulerService; + @Autowired + private KhoaApiService khoaApiService; + @Autowired + private OpenMeteoService openMeteoService; + + @Test + @Rollback(value = false) + void should_activate() { + schedulerService.scheduler(); + } + + @Test + @Rollback(value = false) + void should_testKhoaApiService() { + int days = 3; + LocalDate today = LocalDate.now(); + khoaApiService.updateApi(today,today.plusDays(3)); + } + + @Test + @Rollback(false) + void should_testOpenMeteoService() { + int days = 3; + LocalDate today = LocalDate.now(); + openMeteoService.updateApi(today, today.plusDays(days)); + } +} diff --git a/src/test/java/sevenstar/marineleisure/global/jwt/JwtTokenProviderTest.java b/src/test/java/sevenstar/marineleisure/global/jwt/JwtTokenProviderTest.java new file mode 100644 index 00000000..81988893 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/global/jwt/JwtTokenProviderTest.java @@ -0,0 +1,295 @@ +package sevenstar.marineleisure.global.jwt; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import sevenstar.marineleisure.member.domain.Member; + +@ExtendWith(MockitoExtension.class) +class JwtTokenProviderTest { + + @Mock + private BlacklistedRefreshTokenRepository blacklistedRefreshTokenRepository; + + @Mock + private RedisBlacklistedTokenRepository redisBlacklistedTokenRepository; + + @InjectMocks + private JwtTokenProvider jwtTokenProvider; + + private Member testMember; + private String secretKey = "testSecretKeyWithAtLeast32Characters1234567890"; + + @BeforeEach + void setUp() { + // 필요한 프로퍼티 설정 + ReflectionTestUtils.setField(jwtTokenProvider, "secretKey", secretKey); + ReflectionTestUtils.setField(jwtTokenProvider, "accessTokenValidityInSeconds", 3600L); // 1시간 + ReflectionTestUtils.setField(jwtTokenProvider, "refreshTokenValidityInSeconds", 86400L); // 24시간 + + // init 메서드 호출 + jwtTokenProvider.init(); + + // 테스트용 Member 객체 생성 + testMember = Member.builder() + .nickname("testUser") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .latitude(BigDecimal.valueOf(37.5665)) + .longitude(BigDecimal.valueOf(126.9780)) + .build(); + + // ID 설정 (리플렉션 사용) + ReflectionTestUtils.setField(testMember, "id", 1L); + } + + @Test + @DisplayName("액세스 토큰을 생성할 수 있다") + void createAccessToken() { + // when + String accessToken = jwtTokenProvider.createAccessToken(testMember); + + // then + assertThat(accessToken).isNotNull(); + + // 토큰 검증 + Claims claims = Jwts.parser() + .verifyWith((SecretKey)ReflectionTestUtils.getField(jwtTokenProvider, "key")) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + + assertThat(claims.getSubject()).isEqualTo("1"); + assertThat(claims.get("token_type")).isEqualTo("access"); + assertThat(claims.get("memberId")).isEqualTo(1); + assertThat(claims.get("email")).isEqualTo("test@example.com"); + + // 만료 시간 검증 (현재 시간 + 1시간 이내) + long expirationTime = claims.getExpiration().getTime(); + long currentTime = System.currentTimeMillis(); + long oneHourInMillis = 60 * 60 * 1000; + + assertThat(expirationTime).isGreaterThan(currentTime); + assertThat(expirationTime).isLessThanOrEqualTo(currentTime + oneHourInMillis); + } + + @Test + @DisplayName("리프레시 토큰을 생성할 수 있다") + void createRefreshToken() { + // when + String refreshToken = jwtTokenProvider.createRefreshToken(testMember); + + // then + assertThat(refreshToken).isNotNull(); + + // 토큰 검증 + Claims claims = Jwts.parser() + .verifyWith((SecretKey)ReflectionTestUtils.getField(jwtTokenProvider, "key")) + .build() + .parseSignedClaims(refreshToken) + .getPayload(); + + assertThat(claims.getSubject()).isEqualTo("1"); + assertThat(claims.get("token_type")).isEqualTo("refresh"); + assertThat(claims.get("memberId")).isEqualTo(1); + assertThat(claims.get("email")).isEqualTo("test@example.com"); + assertThat(claims.get("jti")).isNotNull(); // JTI 존재 확인 + + // 만료 시간 검증 (현재 시간 + 24시간 이내) + long expirationTime = claims.getExpiration().getTime(); + long currentTime = System.currentTimeMillis(); + long twentyFourHoursInMillis = 24 * 60 * 60 * 1000; + + assertThat(expirationTime).isGreaterThan(currentTime); + assertThat(expirationTime).isLessThanOrEqualTo(currentTime + twentyFourHoursInMillis); + } + + @Test + @DisplayName("유효한 토큰을 검증할 수 있다") + void validateToken_validToken() { + // given + String token = jwtTokenProvider.createAccessToken(testMember); + + // when + boolean isValid = jwtTokenProvider.validateToken(token); + + // then + assertThat(isValid).isTrue(); + } + + @Test + @DisplayName("만료된 토큰은 유효하지 않다") + void validateToken_expiredToken() { + // given + // 만료된 토큰 생성 (현재 시간 - 1시간) + Date now = new Date(); + Date expiration = new Date(now.getTime() - 3600000); // 1시간 전 + + SecretKey key = (SecretKey)ReflectionTestUtils.getField(jwtTokenProvider, "key"); + String expiredToken = Jwts.builder() + .subject(testMember.getId().toString()) + .claim("token_type", "access") + .claim("memberId", testMember.getId()) + .claim("email", testMember.getEmail()) + .issuedAt(now) + .expiration(expiration) + .signWith(key) + .compact(); + + // when + boolean isValid = jwtTokenProvider.validateToken(expiredToken); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("유효한 리프레시 토큰을 검증할 수 있다") + void validateRefreshToken_validToken() { + // given + String refreshToken = jwtTokenProvider.createRefreshToken(testMember); + + // Redis와 RDB에서 블랙리스트 확인 결과 설정 + when(redisBlacklistedTokenRepository.isBlacklisted(refreshToken)).thenReturn(false); + when(blacklistedRefreshTokenRepository.existsByJti(anyString())).thenReturn(false); + + // when + boolean isValid = jwtTokenProvider.validateRefreshToken(refreshToken); + + // then + assertThat(isValid).isTrue(); + + // 검증 메서드 호출 확인 + verify(redisBlacklistedTokenRepository).isBlacklisted(refreshToken); + verify(blacklistedRefreshTokenRepository).existsByJti(anyString()); + } + + @Test + @DisplayName("Redis 블랙리스트에 있는 리프레시 토큰은 유효하지 않다") + void validateRefreshToken_blacklistedInRedis() { + // given + String refreshToken = jwtTokenProvider.createRefreshToken(testMember); + + // Redis 블랙리스트에 있는 것으로 설정 + when(redisBlacklistedTokenRepository.isBlacklisted(refreshToken)).thenReturn(true); + + // when + boolean isValid = jwtTokenProvider.validateRefreshToken(refreshToken); + + // then + assertThat(isValid).isFalse(); + + // Redis 확인 후 RDB는 확인하지 않아야 함 + verify(redisBlacklistedTokenRepository).isBlacklisted(refreshToken); + verify(blacklistedRefreshTokenRepository, never()).existsByJti(anyString()); + } + + @Test + @DisplayName("RDB 블랙리스트에 있는 리프레시 토큰은 유효하지 않다") + void validateRefreshToken_blacklistedInRDB() { + // given + String refreshToken = jwtTokenProvider.createRefreshToken(testMember); + + // Redis에는 없지만 RDB에는 있는 것으로 설정 + when(redisBlacklistedTokenRepository.isBlacklisted(refreshToken)).thenReturn(false); + when(blacklistedRefreshTokenRepository.existsByJti(anyString())).thenReturn(true); + + // when + boolean isValid = jwtTokenProvider.validateRefreshToken(refreshToken); + + // then + assertThat(isValid).isFalse(); + + // 두 저장소 모두 확인해야 함 + verify(redisBlacklistedTokenRepository).isBlacklisted(refreshToken); + verify(blacklistedRefreshTokenRepository).existsByJti(anyString()); + } + + @Test + @DisplayName("리프레시 토큰을 블랙리스트에 추가할 수 있다") + void blacklistRefreshToken() { + // given + String refreshToken = jwtTokenProvider.createRefreshToken(testMember); + String jti = jwtTokenProvider.getJti(refreshToken); + + // 블랙리스트 저장 설정 + when(blacklistedRefreshTokenRepository.save(any(BlacklistedRefreshToken.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + jwtTokenProvider.blacklistRefreshToken(refreshToken); + + // then + // Redis와 RDB에 저장되었는지 확인 + verify(redisBlacklistedTokenRepository).addToBlacklist(eq(refreshToken), anyLong()); + verify(blacklistedRefreshTokenRepository).save(any(BlacklistedRefreshToken.class)); + } + + @Test + @DisplayName("토큰에서 인증 정보를 추출할 수 있다") + void getAuthentication() { + // given + String token = jwtTokenProvider.createAccessToken(testMember); + + // when + Authentication authentication = jwtTokenProvider.getAuthentication(token); + + // then + assertThat(authentication).isNotNull(); + + // principal 이 UserPrincipal 인지 확인하고, ID·이메일 검증 + assertThat(authentication.getPrincipal()).isInstanceOf(UserPrincipal.class); + UserPrincipal principal = (UserPrincipal)authentication.getPrincipal(); + assertThat(principal.getId()).isEqualTo(testMember.getId()); + assertThat(principal.getUsername()).isEqualTo("test@example.com"); + + // credentials 는 null + assertThat(authentication.getCredentials()).isNull(); + } + + @Test + @DisplayName("토큰에서 회원 ID를 추출할 수 있다") + void getMemberId() { + // given + String token = jwtTokenProvider.createRefreshToken(testMember); + + // when + Long memberId = jwtTokenProvider.getMemberId(token); + + // then + assertThat(memberId).isEqualTo(1L); + } + + @Test + @DisplayName("리프레시 토큰에서 JTI를 추출할 수 있다") + void getJti() { + // given + String refreshToken = jwtTokenProvider.createRefreshToken(testMember); + + // when + String jti = jwtTokenProvider.getJti(refreshToken); + + // then + assertThat(jti).isNotNull(); + assertThat(jti).isNotEmpty(); + } +} diff --git a/src/test/java/sevenstar/marineleisure/global/util/CurrentUserUtilTest.java b/src/test/java/sevenstar/marineleisure/global/util/CurrentUserUtilTest.java new file mode 100644 index 00000000..6a3facb6 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/global/util/CurrentUserUtilTest.java @@ -0,0 +1,193 @@ +package sevenstar.marineleisure.global.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; + +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.MemberErrorCode; +import sevenstar.marineleisure.global.jwt.UserPrincipal; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CurrentUserUtilTest { + + @Test + @DisplayName("인증된 사용자의 ID를 가져올 수 있다") + void getCurrentUserId() { + // given + Long userId = 1L; + UserPrincipal principal = new UserPrincipal(userId, "test@example.com", null); + Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, null); + SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getAuthentication()).thenReturn(authentication); + + // when & then + try (MockedStatic securityContextHolder = Mockito.mockStatic( + SecurityContextHolder.class)) { + securityContextHolder.when(SecurityContextHolder::getContext).thenReturn(securityContext); + + Long currentUserId = CurrentUserUtil.getCurrentUserId(); + + assertThat(currentUserId).isEqualTo(userId); + } + } + + @Test + @DisplayName("인증되지 않은 경우 getCurrentUserId 호출 시 예외가 발생한다") + void getCurrentUserId_notAuthenticated() { + // given + SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getAuthentication()).thenReturn(null); + + // when & then + try (MockedStatic securityContextHolder = Mockito.mockStatic( + SecurityContextHolder.class)) { + securityContextHolder.when(SecurityContextHolder::getContext).thenReturn(securityContext); + + assertThatThrownBy(() -> CurrentUserUtil.getCurrentUserId()) + .isInstanceOf(CustomException.class) + .hasMessageContaining(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + } + + @Test + @DisplayName("인증된 사용자의 이메일을 가져올 수 있다") + void getCurrentUserEmail() { + // given + String email = "test@example.com"; + UserPrincipal principal = new UserPrincipal(1L, email, null); + Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, null); + SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getAuthentication()).thenReturn(authentication); + + // when & then + try (MockedStatic securityContextHolder = Mockito.mockStatic( + SecurityContextHolder.class)) { + securityContextHolder.when(SecurityContextHolder::getContext).thenReturn(securityContext); + + String currentUserEmail = CurrentUserUtil.getCurrentUserEmail(); + + assertThat(currentUserEmail).isEqualTo(email); + } + } + + @Test + @DisplayName("인증되지 않은 경우 getCurrentUserEmail 호출 시 예외가 발생한다") + void getCurrentUserEmail_notAuthenticated() { + // given + SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getAuthentication()).thenReturn(null); + + // when & then + try (MockedStatic securityContextHolder = Mockito.mockStatic( + SecurityContextHolder.class)) { + securityContextHolder.when(SecurityContextHolder::getContext).thenReturn(securityContext); + + assertThatThrownBy(() -> CurrentUserUtil.getCurrentUserEmail()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("인증된 사용자가 아닙니다"); + } + } + + @Test + @DisplayName("사용자가 인증되었는지 확인할 수 있다 — 인증된 경우") + void isAuthenticated_true() { + // given: SecurityContextHolder 에 실체 Authentication 설정 + UserPrincipal principal = new UserPrincipal(1L, "test@example.com", null); + Authentication auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); + SecurityContextHolder.setContext(new SecurityContextImpl(auth)); + + // when + boolean authenticated = CurrentUserUtil.isAuthenticated(); + + // then + assertThat(authenticated).isTrue(); + } + + @Test + @DisplayName("사용자가 인증되었는지 확인할 수 있다 — 인증되지 않은 경우") + void isAuthenticated_false() { + // given: 컨텍스트를 비워서 anonymous 로 만듦 + SecurityContextHolder.clearContext(); + + // when + boolean authenticated = CurrentUserUtil.isAuthenticated(); + + // then + assertThat(authenticated).isFalse(); + } + + @Test + @DisplayName("인증되지 않은 경우 isAuthenticated는 false를 반환한다") + void isAuthenticated_notAuthenticated() { + // given + SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getAuthentication()).thenReturn(null); + + // when & then + try (MockedStatic securityContextHolder = Mockito.mockStatic( + SecurityContextHolder.class)) { + securityContextHolder.when(SecurityContextHolder::getContext).thenReturn(securityContext); + + boolean authenticated = CurrentUserUtil.isAuthenticated(); + + assertThat(authenticated).isFalse(); + } + } + + @Test + @DisplayName("인증 객체가 있지만 인증되지 않은 경우 isAuthenticated는 false를 반환한다") + void isAuthenticated_authenticationNotAuthenticated() { + // given + Authentication authentication = mock(Authentication.class); + SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(false); + + // when & then + try (MockedStatic securityContextHolder = Mockito.mockStatic( + SecurityContextHolder.class)) { + securityContextHolder.when(SecurityContextHolder::getContext).thenReturn(securityContext); + + boolean authenticated = CurrentUserUtil.isAuthenticated(); + + assertThat(authenticated).isFalse(); + } + } + + @Test + @DisplayName("인증 객체가 있지만 Principal이 UserPrincipal이 아닌 경우 isAuthenticated는 false를 반환한다") + void isAuthenticated_principalNotUserPrincipal() { + // given + Authentication authentication = mock(Authentication.class); + SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn("not a UserPrincipal"); + + // when & then + try (MockedStatic securityContextHolder = Mockito.mockStatic( + SecurityContextHolder.class)) { + securityContextHolder.when(SecurityContextHolder::getContext).thenReturn(securityContext); + + boolean authenticated = CurrentUserUtil.isAuthenticated(); + + assertThat(authenticated).isFalse(); + } + } +} \ No newline at end of file diff --git a/src/test/java/sevenstar/marineleisure/global/utils/GeoUtilsTest.java b/src/test/java/sevenstar/marineleisure/global/utils/GeoUtilsTest.java new file mode 100644 index 00000000..63b4e979 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/global/utils/GeoUtilsTest.java @@ -0,0 +1,28 @@ +package sevenstar.marineleisure.global.utils; + +import static org.assertj.core.api.Assertions.*; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; + +class GeoUtilsTest { + private GeoUtils geoUtils = new GeoUtils(new GeometryFactory(new PrecisionModel(), 4326)); + + @Test + void should_success() { + // given + BigDecimal latitude = BigDecimal.valueOf(37.5665); + BigDecimal longitude = BigDecimal.valueOf(126.978); + + // when + Point point = geoUtils.createPoint(latitude, longitude); + + // then + assertThat(point).isNotNull(); + } + +} \ No newline at end of file diff --git a/src/test/java/sevenstar/marineleisure/integration/AuthenticationIntegrationTest.java b/src/test/java/sevenstar/marineleisure/integration/AuthenticationIntegrationTest.java new file mode 100644 index 00000000..87a53e0c --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/integration/AuthenticationIntegrationTest.java @@ -0,0 +1,150 @@ +package sevenstar.marineleisure.integration; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import sevenstar.marineleisure.AbstractTest; +import sevenstar.marineleisure.member.dto.KakaoTokenResponse; +import sevenstar.marineleisure.member.service.OauthService; + +// @SpringBootTest +@AutoConfigureMockMvc +public class AuthenticationIntegrationTest extends AbstractTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private OauthService oauthService; + + private KakaoTokenResponse kakaoTokenResponse; + private Map kakaoUserInfo; + + @BeforeEach + void setUp() { + // 카카오 토큰 응답 설정 + kakaoTokenResponse = KakaoTokenResponse.builder() + .accessToken("kakao-access-token") + .tokenType("bearer") + .refreshToken("kakao-refresh-token") + .expiresIn(3600L) + .build(); + + // 카카오 사용자 정보 설정 + kakaoUserInfo = new HashMap<>(); + kakaoUserInfo.put("id", 12345L); + kakaoUserInfo.put("email", "test@example.com"); + kakaoUserInfo.put("nickname", "testUser"); + } + + // @Test + // @DisplayName("전체 인증 흐름: 로그인 → 토큰 재발급 → 회원 정보 조회 → 로그아웃") + // void fullAuthenticationFlow() throws Exception { + // + // when(oauthService.getKakaoLoginUrl(anyString())).thenReturn(stubUrl); + // // 1. 카카오 로그인 URL 요청 + // MvcResult urlResult = mockMvc.perform(get("/auth/kakao/url")) + // .andExpect(status().isOk()) + // .andExpect(jsonPath("$.code").value(200)) + // .andExpect(jsonPath("$.body.kakaoAuthUrl").exists()) + // .andExpect(jsonPath("$.body.state").exists()) + // .andReturn(); + // + // // 응답에서 state 추출 + // String responseJson = urlResult.getResponse().getContentAsString(); + // Map responseMap = objectMapper.readValue(responseJson, Map.class); + // Map body = (Map)responseMap.get("body"); + // String state = body.get("state"); + // + // // 2. 카카오 로그인 처리 모킹 + // when(oauthService.exchangeCodeForToken(anyString())).thenReturn(kakaoTokenResponse); + // when(oauthService.processKakaoUser(anyString())).thenReturn(kakaoUserInfo); + // + // // 3. 카카오 로그인 요청 + // AuthCodeRequest authCodeRequest = new AuthCodeRequest("test-auth-code", state); + // MvcResult loginResult = mockMvc.perform(post("/auth/kakao/code") + // .contentType(MediaType.APPLICATION_JSON) + // .content(objectMapper.writeValueAsString(authCodeRequest))) + // .andExpect(status().isOk()) + // .andExpect(jsonPath("$.code").value(200)) + // .andExpect(jsonPath("$.body.accessToken").exists()) + // .andExpect(jsonPath("$.body.userId").exists()) + // .andExpect(jsonPath("$.body.email").exists()) + // .andReturn(); + // + // // 응답에서 액세스 토큰 추출 + // String loginResponseJson = loginResult.getResponse().getContentAsString(); + // Map loginResponseMap = objectMapper.readValue(loginResponseJson, Map.class); + // Map loginBody = (Map)loginResponseMap.get("body"); + // String accessToken = (String)loginBody.get("accessToken"); + // + // // 리프레시 토큰 쿠키 추출 + // Cookie refreshTokenCookie = loginResult.getResponse().getCookie("refresh_token"); + // assertThat(refreshTokenCookie).isNotNull(); + // String refreshToken = refreshTokenCookie.getValue(); + // + // // 4. 액세스 토큰으로 회원 정보 조회 + // mockMvc.perform(get("/members/me") + // .header("Authorization", "Bearer " + accessToken)) + // .andExpect(status().isOk()) + // .andExpect(jsonPath("$.code").value(200)) + // .andExpect(jsonPath("$.body.id").exists()) + // .andExpect(jsonPath("$.body.email").exists()); + // + // // 5. 리프레시 토큰으로 토큰 재발급 + // MvcResult refreshResult = mockMvc.perform(post("/auth/refresh") + // .cookie(refreshTokenCookie)) + // .andExpect(status().isOk()) + // .andExpect(jsonPath("$.code").value(200)) + // .andExpect(jsonPath("$.body.accessToken").exists()) + // .andReturn(); + // + // // 새 리프레시 토큰 쿠키 확인 + // Cookie newRefreshTokenCookie = refreshResult.getResponse().getCookie("refresh_token"); + // assertThat(newRefreshTokenCookie).isNotNull(); + // assertThat(newRefreshTokenCookie.getValue()).isNotEqualTo(refreshToken); + // + // // 6. 로그아웃 + // mockMvc.perform(post("/auth/logout") + // .cookie(newRefreshTokenCookie)) + // .andExpect(status().isOk()) + // .andExpect(jsonPath("$.code").value(200)); + // + // // 로그아웃 후 쿠키 삭제 확인 + // Cookie logoutCookie = loginResult.getResponse().getCookie("refresh_token"); + // if (logoutCookie != null) { + // assertThat(logoutCookie.getMaxAge()).isZero(); + // } + // } + + @Test + @DisplayName("인증 없이 보호된 리소스에 접근하면 400대 응답을 받는다") + void accessProtectedResourceWithoutAuthentication() throws Exception { + mockMvc.perform(get("/members/me")) + .andExpect(status().is4xxClientError()); + } + + @Test + @DisplayName("잘못된 액세스 토큰으로 보호된 리소스에 접근하면 401 Unauthorized 응답을 받는다") + void accessProtectedResourceWithInvalidToken() throws Exception { + mockMvc.perform(get("/members/me") + .header("Authorization", "Bearer invalid-token")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/sevenstar/marineleisure/meeting/controller/MeetingControllerTest.java b/src/test/java/sevenstar/marineleisure/meeting/controller/MeetingControllerTest.java new file mode 100644 index 00000000..582a76dd --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/meeting/controller/MeetingControllerTest.java @@ -0,0 +1,805 @@ +package sevenstar.marineleisure.meeting.controller; + +import static org.hamcrest.Matchers.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestInstance; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.PrecisionModel; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.validation.constraints.Size; +import lombok.extern.slf4j.Slf4j; +import sevenstar.marineleisure.AbstractTest; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.FishingType; +import sevenstar.marineleisure.global.enums.MeetingRole; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.global.enums.MemberStatus; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Participant; +import sevenstar.marineleisure.meeting.domain.Tag; +import sevenstar.marineleisure.meeting.dto.request.CreateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.request.UpdateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.vo.TagList; +import sevenstar.marineleisure.meeting.repository.MeetingRepository; +import sevenstar.marineleisure.meeting.security.WithMockCustomUser; +import sevenstar.marineleisure.meeting.service.MeetingService; + +import sevenstar.marineleisure.meeting.util.TestUtil; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.repository.MemberRepository; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; +import sevenstar.marineleisure.meeting.repository.TagRepository; +import sevenstar.marineleisure.meeting.global.TestSecurityConfig; + + +@Slf4j +// @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, +// properties = {"spring.task.scheduling.enabled=false"}) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("mysql-test") +@TestMethodOrder(MethodOrderer.DisplayName.class) +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@Transactional +@Rollback +class MeetingControllerTest extends AbstractTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MeetingService meetingService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private OutdoorSpotRepository outdoorSpotRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @Autowired + private MeetingRepository meetingRepository; + + @Autowired + private TagRepository tagRepository; + + private TestUtil testUtil; + + @BeforeEach + void setUp() throws Exception { + GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); + + OutdoorSpot testOutdoorSpot = OutdoorSpot.builder() + .name("테스트 해양 레저 스팟") + .category(ActivityCategory.FISHING) // 예시: 낚시 카테고리 + .type(FishingType.BOAT) // 예시: 바다 낚시 (category가 FISHING일 경우) + .location("부산 해운대") + .latitude(new BigDecimal("35.1655")) // 예시 위도 + .longitude(new BigDecimal("129.1355")) // 예시 경도 + .point(geometryFactory.createPoint(new org.locationtech.jts.geom.Coordinate(129.1355, 35.1655))) // 경도, 위도 순서 + .build(); + + + outdoorSpotRepository.save(testOutdoorSpot); + + Member mainTester = Member.builder() + .nickname("mainTester") + .email("mainTester@example.com") + .provider("kakao") + .providerId("kakao7") + .latitude(new BigDecimal("126.0000")) + .longitude(new BigDecimal("273.0000")) + .build(); + memberRepository.save(mainTester); + + Member test1Member = Member.builder() + .nickname("testUser1") + .email("test@example.com") + .provider("google") + .providerId("google12345") + .latitude(new BigDecimal("35.0000")) + .longitude(new BigDecimal("129.0000")) + .build(); + + memberRepository.save(test1Member); + + Member test2member = Member.builder() + .nickname("testUser2") + .email("test1@example.com") + .provider("kakao") + .providerId("kakao123456") + .latitude(new BigDecimal("43.0000")) + .longitude(new BigDecimal("172.0000")) + .build(); + + memberRepository.save(test2member); + + + Member testHostMember = Member.builder() + .nickname("testHost") + .email("host@example.com") + .provider("kakao") + .providerId("kakao12345") + .latitude(new BigDecimal("35.0000")) + .longitude(new BigDecimal("129.0000")) + .build(); + memberRepository.save(testHostMember); + + // TestUtil을 사용하여 더미 데이터 생성 + + Meeting testMeeting = Meeting.builder() + .hostId(testHostMember.getId()) + .spotId(testOutdoorSpot.getId()) + .title("테스트 미팅 타이틀 입니다.") + .description("테스트 미팅 본문 입니다.") + .category(ActivityCategory.FISHING) + .status(MeetingStatus.RECRUITING) + .capacity(5) + .meetingTime(LocalDateTime.now().plusDays(7)) + .build(); + meetingRepository.save(testMeeting); + + Participant hostParticipant = Participant.builder() + .meetingId(testMeeting.getId()) + .userId(testHostMember.getId()) + .role(MeetingRole.HOST) + .build(); + + participantRepository.save(hostParticipant); + + Participant guestParticipant1 = Participant.builder() + .meetingId(testMeeting.getId()) + .userId(test1Member.getId()) + .role(MeetingRole.GUEST) + .build(); + + participantRepository.save(guestParticipant1); + + Participant guestParticipant2 = Participant.builder() + .meetingId(testMeeting.getId()) + .userId(test2member.getId()) + .role(MeetingRole.GUEST) + .build(); + + participantRepository.save(guestParticipant2); + + Tag testTags = Tag.builder() + .meetingId(testMeeting.getId()) + .content(Arrays.asList("낚시","부산","토네이도허리케인")) + .build(); + + tagRepository.save(testTags); + + // 디버깅: 멤버 ID 확인 + System.out.println("=== 생성된 멤버들 ==="); + System.out.println("mainTester ID: " + mainTester.getId()); + System.out.println("test1Member ID: " + test1Member.getId()); + System.out.println("test2member ID: " + test2member.getId()); + System.out.println("testHostMember ID: " + testHostMember.getId()); + System.out.println("=================="); + + // 각 상태별 미팅 데이터 생성 (testHostMember가 호스트인 미팅들) + TestUtil.createMeetings(testHostMember, test1Member, testOutdoorSpot, meetingRepository, tagRepository, participantRepository); + } + + @AfterEach + public void cleanUp() { + // @Transactional + @Rollback으로 자동 롤백되므로 수동 삭제 불필요 + // SecurityContext 정리 + TestUtil.clearSecurityContext(); + } + + @Test + @DisplayName("GET /meetings -- 전체 조회하기") + void getAllMeetings() throws Exception { + // 저장된 데이터 확인 + long meetingCount = meetingRepository.count(); + System.out.println("Total meetings in DB: " + meetingCount); + + MvcResult mvcResult = mockMvc.perform( + get("/meetings") + .param("cursorId", "0") + .param("size", "10") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("GET /meetings -- 페이징 테스트 (다음페이지) ") + void getMeetings_NextPage() throws Exception { + // 페이징 테스트를 위해 추가 데이터 생성 + TestUtil.createMeetings(memberRepository.findAll().get(3), memberRepository.findAll().get(1), outdoorSpotRepository.findAll().get(0), meetingRepository, tagRepository, participantRepository); + + // 먼저 전체 미팅 수 확인 + long totalMeetings = meetingRepository.count(); + log.info("Total meetings in database: {}", totalMeetings); + + // 존재하는 미팅 중 하나의 ID를 cursorId로 사용 + List meetings = meetingRepository.findAll(); + Long cursorId = meetings.isEmpty() ? 0L : meetings.get(0).getId(); + + MvcResult mvcResult = mockMvc.perform( + get("/meetings") + .param("cursorId", String.valueOf(cursorId)) + .param("size","10") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("GET /meetings -- 페이징 테스트 (마지막페이지) ") + void getMeetings_EndPage() throws Exception { + // 페이징 테스트를 위해 추가 데이터 생성 + TestUtil.createMeetings(memberRepository.findAll().get(3), memberRepository.findAll().get(1), outdoorSpotRepository.findAll().get(0), meetingRepository, tagRepository, participantRepository); + + // 마지막 페이지 테스트: 실제 존재하는 마지막 미팅 ID 사용 + List meetings = meetingRepository.findAll(); + Long lastMeetingId = meetings.isEmpty() ? 0L : meetings.get(meetings.size() - 1).getId(); + + MvcResult mvcResult = mockMvc.perform( + get("/meetings") + .param("cursorId", String.valueOf(lastMeetingId)) + .param("size","10") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("GET /meetings/{id}") + void getMeetingDetail() throws Exception { + Long meetingId = meetingRepository.findAll().get(0).getId(); + + MvcResult mvcResult = mockMvc.perform( + get("/meetings/{id}",meetingId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", jsonObject); + } + + + @Test + @DisplayName("GET /meetings/{id} -- 존재하지 않는 미팅 조회 시 404") + void getMeetingDetail_NotFound() throws Exception { + Long nonExistentId = 99999L; + + MvcResult mvcResult = mockMvc.perform( + get("/meetings/{id}",nonExistentId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @WithMockCustomUser(id = 4L, username = "testHost") + @DisplayName("POST /meetings -- 미팅 생성 ") + void createMeeting_Authorized() throws Exception { + OutdoorSpot spot = outdoorSpotRepository.findAll().get(0); + + CreateMeetingRequest request = CreateMeetingRequest.builder() + .title("새로운 미팅") + .category(ActivityCategory.FISHING) + .spotId(spot.getId()) + .description("테스트 미팅입니다.") + .capacity(5) + .meetingTime(LocalDateTime.now().plusDays(4)) + .tags(Arrays.asList("테스트", "낚시")) + .build(); + + MvcResult mvcResult = mockMvc.perform( + post("/meetings") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("POST /meetings -- 미팅 생성 ( 인증 없이는 500 NPE - 테스트 환경 제약 )") + void createMeeting_Unauthorized() throws Exception { + OutdoorSpot spot = outdoorSpotRepository.findAll().get(0); + + CreateMeetingRequest request = CreateMeetingRequest.builder() + .title("새로운 미팅") + .category(ActivityCategory.FISHING) + .spotId(spot.getId()) + .description("테스트 미팅입니다.") + .tags(Arrays.asList("테스트", "낚시")) + .build(); + + MvcResult mvcResult = mockMvc.perform( + post("/meetings") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @WithMockCustomUser(id = 2L, username = "testHos1t") + @DisplayName("Post /meetings/{id}/join -- 미팅참가") + public void joinMeeting_Authorized() throws Exception { + List meetings = meetingRepository.findAll(); + Long existingMeetingId = meetings.get(0).getId(); + + log.info("existingMeetingId == {}", existingMeetingId); + + MvcResult mvcResult = mockMvc.perform( + post("/meetings/{id}/join",existingMeetingId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andReturn(); + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("Post /meeting/{id}/join -- 미팅 참가 (인증없이는 500 NPE - 테스트 환경 제약)") + public void joinMeeting_Unauthorized() throws Exception { + List meetings = meetingRepository.findAll(); + Long existingMeetingId = meetings.get(0).getId(); + log.info("existingMeetingId == {}", existingMeetingId); + + MvcResult mvcResult = mockMvc.perform( + post("/meetings/{id}/join",existingMeetingId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isInternalServerError()) + .andReturn(); + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @WithAnonymousUser + @DisplayName("Get /meetings/my -- 내 미팅 목록 ( 인증 없이는 500 NPE - 테스트 환경 제약 )") + void getMyMeeting_Unauthorized() throws Exception { + // 이론: JWT 필터에서 401 반환 + // 현실: 테스트 환경에서 @DirtiesContext + @Transactional로 인한 Spring Security 필터 체인 이슈 + // 결과: UserPrincipal이 null로 주입되어 NPE 발생 + MvcResult mvcResult = mockMvc.perform( + get("/meetings/my") + .param("status","RECRUITING") + .param("cursorId" , "0") + .param("size","10") + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isInternalServerError()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + @Test + @WithMockCustomUser(id = 4L, username = "testHost") + @DisplayName("GET /meetings/my status:RECRUITING -- 인증된 사용자의 미팅 목록") + void getMeeting_WithAuth() throws Exception { + MvcResult mvcResult = mockMvc.perform( + get("/meetings/my") + .param("status","RECRUITING") + .param("cursorId","0") + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andReturn(); + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + + } + + @Test + @WithMockCustomUser(id = 4L, username = "testHost") + @DisplayName("GET /meetings/my status:ONGOING -- 인증된 사용자의 미팅 목록") + void getMeeting_WithAuth_ONGOING() throws Exception { + MvcResult mvcResult = mockMvc.perform( + get("/meetings/my") + .param("status","ONGOING") + .param("cursorId","0") + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andReturn(); + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @WithMockCustomUser(id = 4L, username = "testHost") + @DisplayName("GET /meetings/my status : FULL -- 인증된 사용자의 미팅 목록") + void getMeeting_WithAuth_FULL() throws Exception { + MvcResult mvcResult = mockMvc.perform( + get("/meetings/my") + .param("status","FULL") + .param("cursorId","0") + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andReturn(); + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @WithMockCustomUser(id = 4L, username = "testHost") + @DisplayName("GET /meetings/my status : COMPLETED -- 인증된 사용자의 미팅 목록") + void getMeetings_withAuth_COMPLETED() throws Exception { + MvcResult mvcResult = mockMvc.perform( + get("/meetings/my") + .param("status","COMPLETED") + .param("cursorId","0") + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andReturn(); + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @WithMockCustomUser(id = 4L, username = "testHost") + @DisplayName("GET /meetings/count -- 미팅개수 조회") + void countMeetings_Authorized() throws Exception { + MvcResult mvcResult = mockMvc.perform( + get("/meetings/count") + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @WithMockCustomUser(id = 4L, username = "testHost") + @DisplayName("GET /meetings/{id}/members") + void getMeetingDetailAndMember_Authorized() throws Exception { + List meetings = meetingRepository.findAll(); + Long existingMeetingId = meetings.get(3).getId(); + MvcResult mvcResult = mockMvc.perform( + get("/meetings/{id}/members",existingMeetingId) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("GET /meetings/{id}/members -- 인증없이는 500 NPE - 테스트 환경 제약") + void getMeetingDetailAndMember_NotAuthorized() throws Exception { + List meetings = meetingRepository.findAll(); + Long existingMeetingId = meetings.get(3).getId(); + MvcResult mvcResult = mockMvc.perform( + get("/meetings/{id}/members",existingMeetingId) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isInternalServerError()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("PUT /meetings/{id}/update -- 미팅 수정 성공") + void updateMeetingDetailAndMember_Authorized() throws Exception { + // TestUtil의 SecurityContext 설정 사용 + TestUtil.setupSecurityContext(4L, "host@example.com"); + List meetings = meetingRepository.findAll(); + + // 디버깅: 생성된 모든 미팅의 호스트 ID 확인 + System.out.println("=== 생성된 미팅들 ==="); + meetings.forEach(m -> System.out.println("Meeting ID: " + m.getId() + ", Host ID: " + m.getHostId())); + System.out.println("==================="); + + // 첫 번째 미팅을 사용 (호스트 ID가 4L인지 확인) + Meeting firstMeeting = meetings.get(0); + System.out.println("첫 번째 미팅의 호스트 ID: " + firstMeeting.getHostId()); + Long hostMeetingId = firstMeeting.getId(); + + List spots = outdoorSpotRepository.findAll(); + if (spots.isEmpty()) { + throw new IllegalStateException("테스트용 OutdoorSpot이 존재하지 않습니다."); + } + Long spotId = spots.get(0).getId(); + + UpdateMeetingRequest updateMeetingRequest = UpdateMeetingRequest.builder() + .title("수정된 미팅 제목") + .category(ActivityCategory.SURFING) + .capacity(8) + .localDateTime(LocalDateTime.now().plusDays(8)) + .spotId(spotId) + .description("수정된 미팅 설명입니다.") + .tag(new TagList(Arrays.asList("수정","서핑","주말"))) + .build(); + + MvcResult mvcResult = mockMvc.perform( + put("/meetings/{id}/update",hostMeetingId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateMeetingRequest))) + + + .andExpect(status().isOk()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("DELETE /meetings/{id}/leave -- 미팅 탈퇴 성공") + void leaveMeeting_Authorized() throws Exception { + TestUtil.setupSecurityContext(2L, "test@example.com"); + + List meetings = meetingRepository.findAll(); + Long existingMeetingId = meetings.get(0).getId(); + + MvcResult mvcResult = mockMvc.perform( + delete("/meetings/{id}/leave", existingMeetingId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("DELETE /meetings/{id}/leave -- 미팅 탈퇴 (인증없이는 500 NPE - 테스트 환경 제약)") + void leaveMeeting_Unauthorized() throws Exception { + TestUtil.clearSecurityContext(); // SecurityContext 클리어 + + List meetings = meetingRepository.findAll(); + Long existingMeetingId = meetings.get(0).getId(); + + MvcResult mvcResult = mockMvc.perform( + delete("/meetings/{id}/leave", existingMeetingId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isInternalServerError()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("DELETE /meetings/{id}/leave -- 호스트는 미팅 탈퇴 불가 (409)") + void leaveMeeting_HostCannotLeave() throws Exception { + TestUtil.setupSecurityContext(4L, "host@example.com"); + + List meetings = meetingRepository.findAll(); + Long hostMeetingId = meetings.get(0).getId(); + + MvcResult mvcResult = mockMvc.perform( + delete("/meetings/{id}/leave", hostMeetingId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("DELETE /meetings/{id}/leave -- 존재하지 않는 미팅 탈퇴 시도 (404)") + void leaveMeeting_MeetingNotFound() throws Exception { + TestUtil.setupSecurityContext(2L, "test@example.com"); + + Long nonExistentMeetingId = 99999L; + + MvcResult mvcResult = mockMvc.perform( + delete("/meetings/{id}/leave", nonExistentMeetingId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } + + @Test + @DisplayName("DELETE /meetings/{id}/leave -- 참여하지 않은 미팅 탈퇴 시도 (404)") + void leaveMeeting_NotParticipant() throws Exception { + TestUtil.setupSecurityContext(1L, "mainTester@example.com"); + + + + List meetings = meetingRepository.findAll(); + Long existingMeetingId = meetings.get(0).getId(); + + MvcResult mvcResult = mockMvc.perform( + delete("/meetings/{id}/leave", existingMeetingId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andReturn(); + + String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); + Object jsonObject = objectMapper.readValue(responseBody, Object.class); + String prettyJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(jsonObject); + + log.info("Formatted JSON Response:"); + log.info("prettyJson == {}", prettyJson); + } +} \ No newline at end of file diff --git a/src/test/java/sevenstar/marineleisure/meeting/global/TestAppConfig.java b/src/test/java/sevenstar/marineleisure/meeting/global/TestAppConfig.java new file mode 100644 index 00000000..fc07d5a9 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/meeting/global/TestAppConfig.java @@ -0,0 +1,40 @@ +package sevenstar.marineleisure.meeting.global; + +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; +import sevenstar.marineleisure.meeting.repository.TagRepository; +import sevenstar.marineleisure.meeting.service.MeetingService; +import sevenstar.marineleisure.member.repository.MemberRepository; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@TestConfiguration +public class TestAppConfig { + + @Bean + public MeetingService meetingService() { + return Mockito.mock(MeetingService.class); + } + + @Bean + public MemberRepository memberRepository() { + return Mockito.mock(MemberRepository.class); + } + + @Bean + public OutdoorSpotRepository outdoorSpotSpotRepository() { + return Mockito.mock(OutdoorSpotRepository.class); + } + + @Bean + public TagRepository tagRepository() { + return Mockito.mock(TagRepository.class); + } + + @Bean + public ParticipantRepository participantRepository() { + return Mockito.mock(ParticipantRepository.class); + } +} diff --git a/src/test/java/sevenstar/marineleisure/meeting/global/TestSecurityConfig.java b/src/test/java/sevenstar/marineleisure/meeting/global/TestSecurityConfig.java new file mode 100644 index 00000000..fc5d1bed --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/meeting/global/TestSecurityConfig.java @@ -0,0 +1,31 @@ +package sevenstar.marineleisure.meeting.global; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +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; + +/** + * 테스트용 보안 설정 + * JWT 필터를 비활성화하여 @WithMockCustomUser가 제대로 작동하도록 함 + */ +@TestConfiguration +@EnableWebSecurity +public class TestSecurityConfig { + + @Bean + @Primary + public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable); + return http.build(); + } +} \ No newline at end of file diff --git a/src/test/java/sevenstar/marineleisure/meeting/security/WithMockCustomUser.java b/src/test/java/sevenstar/marineleisure/meeting/security/WithMockCustomUser.java new file mode 100644 index 00000000..d793db65 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/meeting/security/WithMockCustomUser.java @@ -0,0 +1,14 @@ +package sevenstar.marineleisure.meeting.security; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.springframework.security.test.context.support.WithSecurityContext; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class) +public @interface WithMockCustomUser { + long id() default 1L; + String username() default "user"; + String[] roles() default {"USER"}; +} diff --git a/src/test/java/sevenstar/marineleisure/meeting/security/WithMockCustomUserSecurityContextFactory.java b/src/test/java/sevenstar/marineleisure/meeting/security/WithMockCustomUserSecurityContextFactory.java new file mode 100644 index 00000000..71bf3748 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/meeting/security/WithMockCustomUserSecurityContextFactory.java @@ -0,0 +1,29 @@ +package sevenstar.marineleisure.meeting.security; + +import java.util.Collections; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +import sevenstar.marineleisure.global.jwt.UserPrincipal; + +public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithMockCustomUser customUser) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + + UserPrincipal principal = UserPrincipal.builder() + .id(customUser.id()) + .email(customUser.username()) // Use username as email for simplicity + .authorities(Collections.emptyList()) // Or create authorities based on roles() + .build(); + + Authentication auth = new UsernamePasswordAuthenticationToken(principal, "password", principal.getAuthorities()); + context.setAuthentication(auth); + return context; + } +} diff --git a/src/test/java/sevenstar/marineleisure/meeting/service/MeetingServiceImplTest.java b/src/test/java/sevenstar/marineleisure/meeting/service/MeetingServiceImplTest.java new file mode 100644 index 00000000..8584525b --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/meeting/service/MeetingServiceImplTest.java @@ -0,0 +1,319 @@ +package sevenstar.marineleisure.meeting.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.util.ReflectionUtils; + +import sevenstar.marineleisure.global.enums.MeetingRole; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Participant; +import sevenstar.marineleisure.meeting.dto.mapper.MeetingMapper; +import sevenstar.marineleisure.meeting.dto.request.CreateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.request.UpdateMeetingRequest; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailAndMemberResponse; +import sevenstar.marineleisure.meeting.dto.response.MeetingDetailResponse; +import sevenstar.marineleisure.meeting.dto.response.ParticipantResponse; +import sevenstar.marineleisure.meeting.dto.vo.TagList; +import sevenstar.marineleisure.meeting.error.MeetingError; +import sevenstar.marineleisure.meeting.repository.MeetingRepository; +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; +import sevenstar.marineleisure.meeting.repository.TagRepository; +import sevenstar.marineleisure.meeting.validate.MeetingValidate; +import sevenstar.marineleisure.meeting.validate.MemberValidate; +import sevenstar.marineleisure.meeting.validate.ParticipantValidate; +import sevenstar.marineleisure.meeting.validate.SpotValidate; +import sevenstar.marineleisure.meeting.validate.TagValidate; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.repository.MemberRepository; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@ExtendWith(MockitoExtension.class) +class MeetingServiceImplTest { + + @Mock + private MeetingRepository meetingRepository; + @Mock + private ParticipantRepository participantRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private OutdoorSpotRepository outdoorSpotSpotRepository; + @Mock + private TagRepository tagRepository; + @Mock + private ParticipantValidate participantValidate; + @Mock + private MeetingMapper meetingMapper; + @Mock + private MeetingValidate meetingValidate; + @Mock + private MemberValidate memberValidate; + @Mock + private TagValidate tagValidate; + @Mock + private SpotValidate spotValidate; + + @InjectMocks + private MeetingServiceImpl meetingService; + + private Member testMember; + private Meeting testMeeting; + private OutdoorSpot testSpot; + private Member testHost; + private sevenstar.marineleisure.meeting.domain.Tag testTag; + + @BeforeEach + void setUp() { + Member memberWithoutId = Member.builder().nickname("testuser").email("test@test.com").build(); + OutdoorSpot spotWithoutId = OutdoorSpot.builder().name("테스트 장소").location("테스트 위치").build(); + Member hostWithoutId = Member.builder().nickname("host").email("host@test.com").build(); + + testMember = withId(memberWithoutId, 1L); + testSpot = withId(spotWithoutId, 1L); + testHost = withId(hostWithoutId, 2L); + + testMeeting = Meeting.builder() + .id(1L) + .title("테스트 모임") + .capacity(10) + .status(MeetingStatus.ONGOING) + .hostId(testHost.getId()) + .spotId(testSpot.getId()) + .meetingTime(LocalDateTime.now().plusDays(5)) + .build(); + + testTag = sevenstar.marineleisure.meeting.domain.Tag.builder() + .id(1L) + .meetingId(testMeeting.getId()) + .content(Arrays.asList("tag1", "tag2")) + .build(); + } + + @Test + @DisplayName("호스트가 모임 상세 정보와 참여자 목록 조회 성공") + void getMeetingDetailAndMember_Success() { + // given + Long meetingId = testMeeting.getId(); + Long hostId = testHost.getId(); + + Member guestMember = withId(Member.builder().nickname("guest").email("guest@test.com").build(), 3L); + Participant hostParticipant = Participant.builder().meetingId(meetingId).userId(hostId).role(MeetingRole.HOST).build(); + Participant guestParticipant = Participant.builder().meetingId(meetingId).userId(guestMember.getId()).role(MeetingRole.GUEST).build(); + List participants = Arrays.asList(hostParticipant, guestParticipant); + List participantUserIds = Arrays.asList(hostId, guestMember.getId()); + List participantMembers = Arrays.asList(testHost, guestMember); + Map participantNicknames = Map.of(hostId, testHost.getNickname(), guestMember.getId(), guestMember.getNickname()); + + List participantResponses = Arrays.asList( + new ParticipantResponse(hostId, MeetingRole.HOST, testHost.getNickname()), + new ParticipantResponse(guestMember.getId(), MeetingRole.GUEST, guestMember.getNickname()) + ); + + MeetingDetailAndMemberResponse expectedResponse = MeetingDetailAndMemberResponse.builder() + .id(meetingId) + .title(testMeeting.getTitle()) + .hostNickName(testHost.getNickname()) + .participants(participantResponses) + .build(); + + when(memberValidate.foundMember(hostId)).thenReturn(testHost); + when(meetingValidate.foundMeeting(meetingId)).thenReturn(testMeeting); + doNothing().when(meetingValidate).verifyIsHost(anyLong(), anyLong()); + when(spotValidate.foundOutdoorSpot(testMeeting.getSpotId())).thenReturn(testSpot); + when(participantRepository.findParticipantsByMeetingId(meetingId)).thenReturn(participants); + doNothing().when(participantValidate).existParticipant(hostId); + when(memberRepository.findAllById(anyList())).thenReturn(participantMembers); + when(meetingMapper.toParticipantResponseList(anyList(), anyMap())).thenReturn(participantResponses); + //when(meetingMapper.meetingDetailAndMemberResponseMapper(any(), any(), any(), any())).thenReturn(expectedResponse); + + // when + MeetingDetailAndMemberResponse response = meetingService.getMeetingDetailAndMember(hostId, meetingId); + + // then + assertNotNull(response); + assertEquals(meetingId, response.id()); + assertEquals(testHost.getNickname(), response.hostNickName()); + assertEquals(2, response.participants().size()); + assertEquals("host", response.participants().get(0).nickName()); + + verify(memberValidate).foundMember(hostId); + verify(meetingValidate).foundMeeting(meetingId); + verify(meetingValidate).verifyIsHost(hostId, meetingId); + verify(spotValidate).foundOutdoorSpot(testMeeting.getSpotId()); + verify(participantRepository).findParticipantsByMeetingId(meetingId); + verify(memberRepository).findAllById(participantUserIds); + verify(meetingMapper).toParticipantResponseList(participants, participantNicknames); + //verify(meetingMapper).meetingDetailAndMemberResponseMapper(testMeeting, testHost, testSpot, participantResponses); + } + + @Test + @DisplayName("호스트가 아닌 멤버가 조회 시 실패") + void getMeetingDetailAndMember_Fail_NotHost() { + // given + Long meetingId = testMeeting.getId(); + Long nonHostId = testMember.getId(); // 호스트가 아닌 멤버 + + when(memberValidate.foundMember(nonHostId)).thenReturn(testMember); + when(meetingValidate.foundMeeting(meetingId)).thenReturn(testMeeting); + doThrow(new CustomException(MeetingError.MEETING_NOT_HOST)).when(meetingValidate).verifyIsHost(nonHostId, meetingId); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + meetingService.getMeetingDetailAndMember(nonHostId, meetingId); + }); + + assertEquals(MeetingError.MEETING_NOT_HOST, exception.getErrorCode()); + verify(spotValidate, never()).foundOutdoorSpot(anyLong()); + verify(participantRepository, never()).findParticipantsByMeetingId(anyLong()); + } + + // joinMeeting Tests + @Test + @DisplayName("모임 참여 성공") + void joinMeeting_Success() { + // given + doNothing().when(memberValidate).existMember(testMember.getId()); + when(meetingValidate.foundMeeting(testMeeting.getId())).thenReturn(testMeeting); + doNothing().when(meetingValidate).verifyRecruiting(testMeeting); + doNothing().when(participantValidate).verifyNotAlreadyParticipant(testMember.getId(), testMeeting.getId()); + when(participantValidate.getParticipantCount(testMeeting.getId())).thenReturn(5); + doNothing().when(meetingValidate).verifyMeetingCount(5, testMeeting); + when(meetingMapper.saveParticipant(testMember.getId(), testMeeting.getId(), MeetingRole.GUEST)).thenReturn(Participant.builder().build()); + + // when + Long resultMeetingId = meetingService.joinMeeting(testMeeting.getId(), testMember.getId()); + + // then + assertNotNull(resultMeetingId); + assertEquals(testMeeting.getId(), resultMeetingId); + verify(participantRepository, times(1)).save(any(Participant.class)); + } + + @Test + @DisplayName("모임 참여 실패 - 모임 없음") + void joinMeeting_Fail_MeetingNotFound() { + // given + Long nonExistentMeetingId = 99L; + doNothing().when(memberValidate).existMember(testMember.getId()); + when(meetingValidate.foundMeeting(nonExistentMeetingId)).thenThrow(new CustomException(MeetingError.MEETING_NOT_FOUND)); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + meetingService.joinMeeting(nonExistentMeetingId, testMember.getId()); + }); + + assertEquals(MeetingError.MEETING_NOT_FOUND, exception.getErrorCode()); + verify(participantRepository, never()).save(any()); + } + + @Test + @DisplayName("모임 참여 실패 - 모집 중이 아님") + void joinMeeting_Fail_NotOngoing() { + // given + Meeting completedMeeting = Meeting.builder().status(MeetingStatus.COMPLETED).build(); + + doNothing().when(memberValidate).existMember(testMember.getId()); + when(meetingValidate.foundMeeting(completedMeeting.getId())).thenReturn(completedMeeting); + doThrow(new CustomException(MeetingError.MEETING_NOT_RECRUITING)).when(meetingValidate).verifyRecruiting(completedMeeting); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + meetingService.joinMeeting(completedMeeting.getId(), testMember.getId()); + }); + + assertEquals(MeetingError.MEETING_NOT_RECRUITING, exception.getErrorCode()); + verify(participantRepository, never()).save(any()); + } + + @Test + @DisplayName("모임 참여 실패 - 정원 초과") + void joinMeeting_Fail_MeetingFull() { + // given + doNothing().when(memberValidate).existMember(testMember.getId()); + when(meetingValidate.foundMeeting(testMeeting.getId())).thenReturn(testMeeting); + doNothing().when(meetingValidate).verifyRecruiting(testMeeting); + doNothing().when(participantValidate).verifyNotAlreadyParticipant(testMember.getId(), testMeeting.getId()); + when(participantValidate.getParticipantCount(testMeeting.getId())).thenReturn(10); + doThrow(new CustomException(MeetingError.MEETING_ALREADY_FULL)).when(meetingValidate).verifyMeetingCount(10, testMeeting); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + meetingService.joinMeeting(testMeeting.getId(), testMember.getId()); + }); + + assertEquals(MeetingError.MEETING_ALREADY_FULL, exception.getErrorCode()); + verify(participantRepository, never()).save(any()); + } + + // getMeetingDetails Tests + @Test + @DisplayName("모임 상세 조회 성공") + void getMeetingDetails_Success() { + // given + when(meetingValidate.foundMeeting(testMeeting.getId())).thenReturn(testMeeting); + when(memberValidate.foundMember(testMeeting.getHostId())).thenReturn(testHost); + when(spotValidate.foundOutdoorSpot(testMeeting.getSpotId())).thenReturn(testSpot); + when(tagValidate.findByMeetingId(anyLong())).thenReturn(Optional.of(testTag)); + when(meetingMapper.MeetingDetailResponseMapper(testMeeting, testHost, testSpot, testTag)) + .thenReturn(MeetingDetailResponse.builder().title(testMeeting.getTitle()).hostNickName(testHost.getNickname()).build()); + + // when + MeetingDetailResponse response = meetingService.getMeetingDetails(testMeeting.getId()); + + // then + assertNotNull(response); + assertEquals(testMeeting.getTitle(), response.title()); + assertEquals(testHost.getNickname(), response.hostNickName()); + } + + @Test + @DisplayName("모임 상세 조회 실패 - 모임 없음") + void getMeetingDetails_Fail_MeetingNotFound() { + // given + Long nonExistentMeetingId = 99L; + when(meetingValidate.foundMeeting(nonExistentMeetingId)).thenThrow(new CustomException(MeetingError.MEETING_NOT_FOUND)); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + meetingService.getMeetingDetails(nonExistentMeetingId); + }); + + assertEquals(MeetingError.MEETING_NOT_FOUND, exception.getErrorCode()); + } + + private T withId(T entity, Long id) { + try { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + ReflectionUtils.setField(idField, entity, id); + return entity; + } catch (NoSuchFieldException e) { + throw new RuntimeException("Entity does not have an 'id' field", e); + } + } +} diff --git a/src/test/java/sevenstar/marineleisure/meeting/util/TestUtil.java b/src/test/java/sevenstar/marineleisure/meeting/util/TestUtil.java new file mode 100644 index 00000000..6e288da5 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/meeting/util/TestUtil.java @@ -0,0 +1,117 @@ +package sevenstar.marineleisure.meeting.util; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.MeetingRole; +import sevenstar.marineleisure.global.enums.MeetingStatus; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Participant; +import sevenstar.marineleisure.meeting.domain.Tag; +import sevenstar.marineleisure.meeting.repository.MeetingRepository; +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; +import sevenstar.marineleisure.meeting.repository.TagRepository; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; + +public class TestUtil { + + public static List createMeetings( + Member host, Member member, OutdoorSpot spot, MeetingRepository meetingRepository, TagRepository tagRepository, ParticipantRepository participantRepository) { + List meetings = new ArrayList<>(); + ActivityCategory[] categories = ActivityCategory.values(); + + for (MeetingStatus status : MeetingStatus.values()) { + for (int i = 1; i <= 12; i++) { + ActivityCategory category = categories[i % categories.length]; + Member currentHost = host; // 항상 host(testHostMember)를 호스트로 설정 + + meetings.add(Meeting.builder() + .hostId(currentHost.getId()) + .spotId(spot.getId()) + .title("모임" + i) + .description("테스트 모임입니다.") + .category(category) + .status(status) + .capacity(5 + i) // 다양한 인원 + .meetingTime(createMeetingTimeForStatus(status, i)) // 상태에 따른 시간 설정 + .build()); + } + } + + List savedMeetings = meetingRepository.saveAll(meetings); + + // 각 미팅에 대해 Tag와 Participant 생성 + for (Meeting meeting : savedMeetings) { + // Tag 생성 + Tag tag = Tag.builder() + .meetingId(meeting.getId()) + .content(Arrays.asList("테스트", "모임")) + .build(); + tagRepository.save(tag); + + // Participant 생성 (호스트는 항상 참여자) + Participant hostParticipant = Participant.builder() + .meetingId(meeting.getId()) + .userId(meeting.getHostId()) + .role(MeetingRole.HOST) + .build(); + participantRepository.save(hostParticipant); + + // 다른 멤버를 게스트로 참여시킴 + Participant guestParticipant = Participant.builder() + .meetingId(meeting.getId()) + .userId(member.getId()) + .role(MeetingRole.GUEST) + .build(); + participantRepository.save(guestParticipant); + } + + return savedMeetings; + } + + private static LocalDateTime createMeetingTimeForStatus(MeetingStatus status, int offset) { + switch (status) { + case RECRUITING: + case FULL: + // 모집중, 인원마감 상태는 미래의 모임 + return LocalDateTime.now().plusDays(offset); + case ONGOING: + // 진행중 상태는 현재 또는 아주 최근의 모임 + return LocalDateTime.now().minusHours(offset); + case COMPLETED: + // 완료 상태는 과거의 모임 + return LocalDateTime.now().minusDays(offset); + default: + return LocalDateTime.now(); + } + } + + public static void setupSecurityContext(Long userId, String email) { + sevenstar.marineleisure.global.jwt.UserPrincipal userPrincipal = + sevenstar.marineleisure.global.jwt.UserPrincipal.builder() + .id(userId) + .email(email) + .authorities(java.util.Collections.emptyList()) + .build(); + + org.springframework.security.authentication.UsernamePasswordAuthenticationToken authentication = + new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + userPrincipal, "password", userPrincipal.getAuthorities()); + + org.springframework.security.core.context.SecurityContext context = + org.springframework.security.core.context.SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + org.springframework.security.core.context.SecurityContextHolder.setContext(context); + } + + public static void clearSecurityContext() { + org.springframework.security.core.context.SecurityContextHolder.clearContext(); + } + + +} + diff --git a/src/test/java/sevenstar/marineleisure/member/controller/AuthControllerTest.java b/src/test/java/sevenstar/marineleisure/member/controller/AuthControllerTest.java new file mode 100644 index 00000000..81dd2382 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/member/controller/AuthControllerTest.java @@ -0,0 +1,292 @@ +package sevenstar.marineleisure.member.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import sevenstar.marineleisure.global.exception.enums.MemberErrorCode; +import sevenstar.marineleisure.global.jwt.JwtTokenProvider; +import sevenstar.marineleisure.member.dto.AuthCodeRequest; +import sevenstar.marineleisure.member.dto.LoginResponse; +import sevenstar.marineleisure.member.service.AuthService; +import sevenstar.marineleisure.member.service.OauthService; + +@WebMvcTest(AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + // @MockBean → @MockitoBean + @MockitoBean + private AuthService authService; + @MockitoBean + private OauthService oauthService; + @MockitoBean + private JwtTokenProvider jwtTokenProvider; + + private LoginResponse loginResponseCookie; + private LoginResponse loginResponseNoCookie; + + @BeforeEach + void setUp() { + // 쿠키 모드용 응답 (refreshToken 없음) + loginResponseCookie = LoginResponse.builder() + .accessToken("test-access-token") + .userId(1L) + .email("test@example.com") + .nickname("testUser") + .build(); + + // 비쿠키 모드용 응답 (refreshToken 포함) + loginResponseNoCookie = LoginResponse.builder() + .accessToken("test-access-token") + .userId(1L) + .email("test@example.com") + .nickname("testUser") + .refreshToken("test-refresh-token") + .build(); + } + + @Test + @DisplayName("카카오 로그인 URL을 요청할 수 있다") + void getKakaoLoginUrl() throws Exception { + Map loginUrlInfo = new HashMap<>(); + loginUrlInfo.put("kakaoAuthUrl", + "https://kauth.kakao.com/oauth/authorize?client_id=test-api-key" + + "&redirect_uri=http://localhost:8080/oauth/kakao/code" + + "&response_type=code&state=test-state"); + loginUrlInfo.put("state", "test-state"); + loginUrlInfo.put("encryptedState", "encrypted-test-state"); + loginUrlInfo.put("accessToken", "test-access-token"); + + when(oauthService.getKakaoLoginUrl(isNull(), any())).thenReturn(loginUrlInfo); + + mockMvc.perform(get("/auth/kakao/url")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.kakaoAuthUrl").exists()) + .andExpect(jsonPath("$.body.state").value("test-state")) + .andExpect(jsonPath("$.body.encryptedState").value("encrypted-test-state")) + .andExpect(jsonPath("$.body.accessToken").value("test-access-token")); + } + + @Test + @DisplayName("커스텀 리다이렉트 URI로 카카오 로그인 URL을 요청할 수 있다") + void getKakaoLoginUrlWithCustomRedirectUri() throws Exception { + String customRedirectUri = "http://custom-redirect.com/callback"; + Map loginUrlInfo = new HashMap<>(); + loginUrlInfo.put("kakaoAuthUrl", + "https://kauth.kakao.com/oauth/authorize?client_id=test-api-key" + + "&redirect_uri=" + customRedirectUri + + "&response_type=code&state=test-state"); + loginUrlInfo.put("state", "test-state"); + loginUrlInfo.put("encryptedState", "encrypted-test-state"); + loginUrlInfo.put("accessToken", "test-access-token"); + + when(oauthService.getKakaoLoginUrl(any(), any())).thenReturn(loginUrlInfo); + + mockMvc.perform(get("/auth/kakao/url").param("redirectUri", customRedirectUri)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.kakaoAuthUrl").exists()) + .andExpect(jsonPath("$.body.state").value("test-state")) + .andExpect(jsonPath("$.body.encryptedState").value("encrypted-test-state")) + .andExpect(jsonPath("$.body.accessToken").value("test-access-token")); + } + + @Test + @DisplayName("카카오 로그인을 처리할 수 있다 (쿠키 모드)") + void kakaoLogin() throws Exception { + AuthCodeRequest request = new AuthCodeRequest("test-auth-code", "test-state", "encrypted-test-state", null, + null); + when(authService.processKakaoLogin(eq("test-auth-code"), eq("test-state"), eq("encrypted-test-state"), any( + HttpServletResponse.class))).thenReturn(loginResponseCookie); + + mockMvc.perform(post("/auth/kakao/code") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.accessToken").value("test-access-token")) + .andExpect(jsonPath("$.body.userId").value(1)) + .andExpect(jsonPath("$.body.email").value("test@example.com")) + .andExpect(jsonPath("$.body.nickname").value("testUser")) + .andExpect(jsonPath("$.body.refreshToken").doesNotExist()); // 쿠키 모드에서는 refreshToken이 응답에 포함되지 않음 + } + + @Test + @DisplayName("카카오 로그인을 처리할 수 있다 (비쿠키 모드)") + void kakaoLogin_noCookie() throws Exception { + AuthCodeRequest request = new AuthCodeRequest("test-auth-code", "test-state", "encrypted-test-state", null, + null); + when(authService.processKakaoLogin(eq("test-auth-code"), eq("test-state"), eq("encrypted-test-state"), any( + HttpServletResponse.class))).thenReturn(loginResponseNoCookie); + + mockMvc.perform(post("/auth/kakao/code") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.accessToken").value("test-access-token")) + .andExpect(jsonPath("$.body.userId").value(1)) + .andExpect(jsonPath("$.body.email").value("test@example.com")) + .andExpect(jsonPath("$.body.nickname").value("testUser")) + .andExpect(jsonPath("$.body.refreshToken").value("test-refresh-token")); // 비쿠키 모드에서는 refreshToken이 응답에 포함됨 + } + + @Test + @DisplayName("카카오 로그인 처리 중 오류가 발생하면 에러 응답을 반환한다") + void kakaoLogin_error() throws Exception { + AuthCodeRequest request = new AuthCodeRequest("invalid-code", "test-state", "encrypted-test-state", null, null); + when(authService.processKakaoLogin(eq("invalid-code"), eq("test-state"), eq("encrypted-test-state"), + any(HttpServletResponse.class))) + .thenThrow(new RuntimeException("Failed to get access token from Kakao")); + + mockMvc.perform(post("/auth/kakao/code") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.KAKAO_LOGIN_ERROR.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.KAKAO_LOGIN_ERROR.getMessage())); + } + + @Test + @DisplayName("사용자가 카카오 로그인을 취소하면 취소 응답을 반환한다") + void kakaoLogin_canceled() throws Exception { + AuthCodeRequest request = new AuthCodeRequest(null, "test-state", "encrypted-test-state", "access_denied", + "User denied access"); + + mockMvc.perform(post("/auth/kakao/code") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1503)) + .andExpect(jsonPath("$.message").value("사용자가 카카오 로그인을 취소했습니다.")); + } + + @Test + @DisplayName("카카오 로그인 중 다른 에러가 발생하면 에러 응답을 반환한다") + void kakaoLogin_otherError() throws Exception { + AuthCodeRequest request = new AuthCodeRequest(null, "test-state", "encrypted-test-state", "server_error", + "Internal server error"); + + mockMvc.perform(post("/auth/kakao/code") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value(1500)); + } + + @Test + @DisplayName("리프레시 토큰으로 새 토큰을 발급할 수 있다 (쿠키 모드)") + void refreshToken() throws Exception { + String refreshToken = "valid-refresh-token"; + when(authService.refreshToken(eq(refreshToken), any())).thenReturn(loginResponseCookie); + + mockMvc.perform(post("/auth/refresh") + .cookie(new Cookie("refresh_token", refreshToken))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.accessToken").value("test-access-token")) + .andExpect(jsonPath("$.body.userId").value(1)) + .andExpect(jsonPath("$.body.email").value("test@example.com")) + .andExpect(jsonPath("$.body.nickname").value("testUser")) + .andExpect(jsonPath("$.body.refreshToken").doesNotExist()); // 쿠키 모드에서는 refreshToken이 응답에 포함되지 않음 + } + + @Test + @DisplayName("리프레시 토큰으로 새 토큰을 발급할 수 있다 (비쿠키 모드)") + void refreshToken_noCookie() throws Exception { + String refreshToken = "valid-refresh-token"; + when(authService.refreshToken(eq(refreshToken), any())).thenReturn(loginResponseNoCookie); + + // 비쿠키 모드에서는 리프레시 토큰을 요청 본문에 포함 + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"" + refreshToken + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.accessToken").value("test-access-token")) + .andExpect(jsonPath("$.body.userId").value(1)) + .andExpect(jsonPath("$.body.email").value("test@example.com")) + .andExpect(jsonPath("$.body.nickname").value("testUser")) + .andExpect(jsonPath("$.body.refreshToken").value("test-refresh-token")); // 비쿠키 모드에서는 refreshToken이 응답에 포함됨 + } + + // @Test + // @DisplayName("리프레시 토큰이 없으면 400을 반환한다") + // void refreshToken_noToken() throws Exception { + // mockMvc.perform(post("/auth/refresh")) + // .andExpect(status().isUnauthorized()); // 400만 검증 + // } + + @Test + @DisplayName("유효하지 않은 리프레시 토큰으로 토큰 재발급 시 에러 응답을 반환한다 (쿠키 모드)") + void refreshToken_invalidToken() throws Exception { + String refreshToken = "invalid-refresh-token"; + when(authService.refreshToken(eq(refreshToken), any())) + .thenThrow(new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.")); + + mockMvc.perform(post("/auth/refresh") + .cookie(new Cookie("refresh_token", refreshToken))) + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.code").value(1402)) + .andExpect(jsonPath("$.message").value("유효하지 않은 리프레시 토큰입니다.")); + } + + @Test + @DisplayName("유효하지 않은 리프레시 토큰으로 토큰 재발급 시 에러 응답을 반환한다 (비쿠키 모드)") + void refreshToken_invalidToken_noCookie() throws Exception { + String refreshToken = "invalid-refresh-token"; + when(authService.refreshToken(eq(refreshToken), any())) + .thenThrow(new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.")); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"" + refreshToken + "\"}")) + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.code").value(1402)) + .andExpect(jsonPath("$.message").value("유효하지 않은 리프레시 토큰입니다.")); + } + + @Test + @DisplayName("로그아웃을 처리할 수 있다") + void logout() throws Exception { + String refreshToken = "valid-refresh-token"; + + mockMvc.perform(post("/auth/logout") + .cookie(new Cookie("refresh_token", refreshToken))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + @DisplayName("리프레시 토큰 없이도 로그아웃을 처리할 수 있다") + void logout_noToken() throws Exception { + mockMvc.perform(post("/auth/logout")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } +} diff --git a/src/test/java/sevenstar/marineleisure/member/controller/MemberControllerTest.java b/src/test/java/sevenstar/marineleisure/member/controller/MemberControllerTest.java new file mode 100644 index 00000000..73511f95 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/member/controller/MemberControllerTest.java @@ -0,0 +1,108 @@ +package sevenstar.marineleisure.member.controller; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import sevenstar.marineleisure.global.enums.MemberStatus; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.MemberErrorCode; +import sevenstar.marineleisure.global.util.CurrentUserUtil; +import sevenstar.marineleisure.member.dto.MemberDetailResponse; +import sevenstar.marineleisure.member.service.MemberService; + +@WebMvcTest(MemberController.class) + //@AutoConfigureMockMvc(addFilters = false) // 시큐리티 필터 비활성화 +class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private MemberService memberService; + + private MemberDetailResponse memberDetailResponse; + + @BeforeEach + void setUp() { + // 테스트용 응답 객체 생성 + memberDetailResponse = MemberDetailResponse.builder() + .id(1L) + .email("test@example.com") + .nickname("testUser") + .status(MemberStatus.ACTIVE) + .latitude(BigDecimal.valueOf(37.5665)) + .longitude(BigDecimal.valueOf(126.9780)) + .build(); + } + + @Test + @DisplayName("현재 로그인한 회원의 상세 정보를 조회할 수 있다") + @WithMockUser + void getCurrentMemberDetail() throws Exception { + // given + try (MockedStatic mockedStatic = Mockito.mockStatic(CurrentUserUtil.class)) { + mockedStatic.when(CurrentUserUtil::getCurrentUserId).thenReturn(1L); + when(memberService.getCurrentMemberDetail(1L)).thenReturn(memberDetailResponse); + + // when & then + mockMvc.perform(get("/members/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.body.id").value(1)) + .andExpect(jsonPath("$.body.email").value("test@example.com")) + .andExpect(jsonPath("$.body.nickname").value("testUser")) + .andExpect(jsonPath("$.body.status").value("ACTIVE")) + .andExpect(jsonPath("$.body.latitude").value(37.5665)) + .andExpect(jsonPath("$.body.longitude").value(126.9780)); + } + } + + @Test + @DisplayName("인증되지 않은 사용자가 회원 정보 조회 시 401 이 발생한다") + void getCurrentMemberDetail_notAuthenticated() throws Exception { + // given + mockMvc.perform(get("/members/me")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("존재하지 않는 회원 ID로 조회 시 예외가 발생한다") + @WithMockUser + void getCurrentMemberDetail_memberNotFound() throws Exception { + // given + try (MockedStatic mockedStatic = Mockito.mockStatic(CurrentUserUtil.class)) { + Long currentUserId = 999L; + mockedStatic.when(CurrentUserUtil::getCurrentUserId).thenReturn(currentUserId); + when(memberService.getCurrentMemberDetail(currentUserId)) + .thenThrow(new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // when & then + // ServletException ex = assertThrows( + // ServletException.class, + // () -> mockMvc.perform(get("/members/me")) + // ); + // + // // 그리고 그 원인이 NoSuchElementException인지, 메시지는 맞는지 추가 검증 + // Throwable cause = ex.getCause(); + // assertThat(cause).isInstanceOf(NoSuchElementException.class); + // assertThat(cause.getMessage()).isEqualTo("회원을 찾을 수 없습니다: 999"); + mockMvc.perform(get("/members/me")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } +} diff --git a/src/test/java/sevenstar/marineleisure/member/domain/MemberTest.java b/src/test/java/sevenstar/marineleisure/member/domain/MemberTest.java new file mode 100644 index 00000000..bffe7e29 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/member/domain/MemberTest.java @@ -0,0 +1,156 @@ +package sevenstar.marineleisure.member.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import sevenstar.marineleisure.global.enums.MemberStatus; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class MemberTest { + + @Test + @DisplayName("빌더 패턴을 사용하여 Member 객체를 생성할 수 있다") + void createMemberWithBuilder() { + // given + String nickname = "testUser"; + String email = "test@example.com"; + String provider = "kakao"; + String providerId = "12345"; + BigDecimal latitude = BigDecimal.valueOf(37.5665); + BigDecimal longitude = BigDecimal.valueOf(126.9780); + + // when + Member member = Member.builder() + .nickname(nickname) + .email(email) + .provider(provider) + .providerId(providerId) + .latitude(latitude) + .longitude(longitude) + .build(); + + // then + assertThat(member).isNotNull(); + assertThat(member.getNickname()).isEqualTo(nickname); + assertThat(member.getEmail()).isEqualTo(email); + assertThat(member.getProvider()).isEqualTo(provider); + assertThat(member.getProviderId()).isEqualTo(providerId); + assertThat(member.getLatitude()).isEqualTo(latitude); + assertThat(member.getLongitude()).isEqualTo(longitude); + assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE); // 기본값 확인 + } + + @Test + @DisplayName("updateNickname 메서드를 사용하여 닉네임을 변경할 수 있다") + void updateNickname() { + // given + Member member = Member.builder() + .nickname("oldNickname") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .build(); + String newNickname = "newNickname"; + + // when + member.updateNickname(newNickname); + + // then + assertThat(member.getNickname()).isEqualTo(newNickname); + } + + @Test + @DisplayName("updateStatus 메서드를 사용하여 회원 상태를 변경할 수 있다") + void updateStatus() { + // given + Member member = Member.builder() + .nickname("testUser") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .build(); + MemberStatus newStatus = MemberStatus.EXPIRED; + + // when + member.updateStatus(newStatus); + + // then + assertThat(member.getStatus()).isEqualTo(newStatus); + } + + @Test + @DisplayName("updateLocation 메서드를 사용하여 위치 정보를 변경할 수 있다") + void updateLocation() { + // given + Member member = Member.builder() + .nickname("testUser") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .latitude(BigDecimal.valueOf(37.5665)) + .longitude(BigDecimal.valueOf(126.9780)) + .build(); + BigDecimal newLatitude = BigDecimal.valueOf(35.1796); + BigDecimal newLongitude = BigDecimal.valueOf(129.0756); + + // when + member.updateLocation(newLatitude, newLongitude); + + // then + assertThat(member.getLatitude()).isEqualTo(newLatitude); + assertThat(member.getLongitude()).isEqualTo(newLongitude); + } + + @Test + @DisplayName("updateLocation 메서드는 null이 아닌 값만 업데이트한다") + void updateLocationWithNullValues() { + // given + BigDecimal initialLatitude = BigDecimal.valueOf(37.5665); + BigDecimal initialLongitude = BigDecimal.valueOf(126.9780); + Member member = Member.builder() + .nickname("testUser") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .latitude(initialLatitude) + .longitude(initialLongitude) + .build(); + + // when: 위도만 업데이트 + BigDecimal newLatitude = BigDecimal.valueOf(35.1796); + member.updateLocation(newLatitude, null); + + // then + assertThat(member.getLatitude()).isEqualTo(newLatitude); + assertThat(member.getLongitude()).isEqualTo(initialLongitude); // 변경되지 않음 + + // when: 경도만 업데이트 + BigDecimal newLongitude = BigDecimal.valueOf(129.0756); + member.updateLocation(null, newLongitude); + + // then + assertThat(member.getLatitude()).isEqualTo(newLatitude); // 이전에 변경된 값 유지 + assertThat(member.getLongitude()).isEqualTo(newLongitude); + } + + @Test + @DisplayName("Member 객체는 BaseEntity를 상속받아 생성 및 수정 시간 정보를 가진다") + void memberExtendsBaseEntity() { + // given + Member member = Member.builder() + .nickname("testUser") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .build(); + + // then + // BaseEntity의 createdAt과 updatedAt은 JPA 영속화 시점에 설정되므로 + // 단위 테스트에서는 null이 예상됨 + assertThat(member.getCreatedAt()).isNull(); + assertThat(member.getUpdatedAt()).isNull(); + } +} diff --git a/src/test/java/sevenstar/marineleisure/member/repository/MemberRepositoryTest.java b/src/test/java/sevenstar/marineleisure/member/repository/MemberRepositoryTest.java new file mode 100644 index 00000000..c8a4c8bc --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/member/repository/MemberRepositoryTest.java @@ -0,0 +1,155 @@ +package sevenstar.marineleisure.member.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.math.BigDecimal; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; + +import sevenstar.marineleisure.AbstractTest; +import sevenstar.marineleisure.annotation.MysqlDataJpaTest; +import sevenstar.marineleisure.global.enums.MemberStatus; +import sevenstar.marineleisure.member.domain.Member; + +class MemberRepositoryTest extends AbstractTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + @DisplayName("Member 엔티티를 저장하고 ID로 조회할 수 있다") + void saveMemberAndFindById() { + // given + Member member = createTestMember("testUser", "test@example.com", "kakao", "12345"); + + // when + Member savedMember = memberRepository.save(member); + entityManager.flush(); + entityManager.clear(); + + // then + Optional foundMember = memberRepository.findById(savedMember.getId()); + assertThat(foundMember).isPresent(); + assertThat(foundMember.get().getNickname()).isEqualTo("testUser"); + assertThat(foundMember.get().getEmail()).isEqualTo("test@example.com"); + assertThat(foundMember.get().getProvider()).isEqualTo("kakao"); + assertThat(foundMember.get().getProviderId()).isEqualTo("12345"); + assertThat(foundMember.get().getLatitude().compareTo(BigDecimal.valueOf(37.5665))).isEqualTo(0); + assertThat(foundMember.get().getLongitude().compareTo(BigDecimal.valueOf(126.9780))).isEqualTo(0); + assertThat(foundMember.get().getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(foundMember.get().getCreatedAt()).isNotNull(); + assertThat(foundMember.get().getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("provider와 providerId로 Member를 조회할 수 있다") + void findByProviderAndProviderId() { + // given + Member member = createTestMember("testUser", "test@example.com", "kakao", "12345"); + memberRepository.save(member); + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundMember = memberRepository.findByProviderAndProviderId("kakao", "12345"); + + // then + assertThat(foundMember).isPresent(); + assertThat(foundMember.get().getNickname()).isEqualTo("testUser"); + assertThat(foundMember.get().getEmail()).isEqualTo("test@example.com"); + assertThat(foundMember.get().getProvider()).isEqualTo("kakao"); + assertThat(foundMember.get().getProviderId()).isEqualTo("12345"); + assertThat(foundMember.get().getLatitude().compareTo(BigDecimal.valueOf(37.5665))).isEqualTo(0); + assertThat(foundMember.get().getLongitude().compareTo(BigDecimal.valueOf(126.9780))).isEqualTo(0); + assertThat(foundMember.get().getStatus()).isEqualTo(MemberStatus.ACTIVE); + } + + @Test + @DisplayName("존재하지 않는 provider와 providerId로 조회하면 빈 Optional을 반환한다") + void findByProviderAndProviderIdNotFound() { + // given + Member member = createTestMember("testUser", "test@example.com", "kakao", "12345"); + memberRepository.save(member); + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundMember = memberRepository.findByProviderAndProviderId("google", "12345"); + + // then + assertThat(foundMember).isEmpty(); + } + + @Test + @DisplayName("Member 엔티티를 수정할 수 있다") + void updateMember() { + // given + Member member = createTestMember("oldNickname", "test@example.com", "kakao", "12345"); + Member savedMember = memberRepository.save(member); + entityManager.flush(); + entityManager.clear(); + + // 수정 전 상태 저장 + Member beforeUpdate = memberRepository.findById(savedMember.getId()).orElseThrow(); + var originalUpdatedAt = beforeUpdate.getUpdatedAt(); + + // 잠시 대기하여 updatedAt 변경 확인을 위한 시간차 생성 + try { + Thread.sleep(10); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + // when + Member foundMember = memberRepository.findById(savedMember.getId()).orElseThrow(); + foundMember.updateNickname("newNickname"); + memberRepository.save(foundMember); + entityManager.flush(); + entityManager.clear(); + + // then + Member updatedMember = memberRepository.findById(savedMember.getId()).orElseThrow(); + assertThat(updatedMember.getNickname()).isEqualTo("newNickname"); + assertThat(updatedMember.getEmail()).isEqualTo("test@example.com"); + assertThat(updatedMember.getProvider()).isEqualTo("kakao"); + assertThat(updatedMember.getProviderId()).isEqualTo("12345"); + assertThat(updatedMember.getUpdatedAt()).isAfter(originalUpdatedAt); + } + + @Test + @DisplayName("Member 엔티티를 삭제할 수 있다") + void deleteMember() { + // given + Member member = createTestMember("testUser", "test@example.com", "kakao", "12345"); + Member savedMember = memberRepository.save(member); + entityManager.flush(); + entityManager.clear(); + + // when + memberRepository.deleteById(savedMember.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + Optional foundMember = memberRepository.findById(savedMember.getId()); + assertThat(foundMember).isEmpty(); + } + + private Member createTestMember(String nickname, String email, String provider, String providerId) { + return Member.builder() + .nickname(nickname) + .email(email) + .provider(provider) + .providerId(providerId) + .latitude(BigDecimal.valueOf(37.5665)) + .longitude(BigDecimal.valueOf(126.9780)) + .build(); + } +} diff --git a/src/test/java/sevenstar/marineleisure/member/service/AuthServiceTest.java b/src/test/java/sevenstar/marineleisure/member/service/AuthServiceTest.java new file mode 100644 index 00000000..6cc49cc3 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/member/service/AuthServiceTest.java @@ -0,0 +1,334 @@ +package sevenstar.marineleisure.member.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import sevenstar.marineleisure.global.jwt.JwtTokenProvider; +import sevenstar.marineleisure.global.util.CookieUtil; +import sevenstar.marineleisure.global.util.StateEncryptionUtil; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.dto.KakaoTokenResponse; +import sevenstar.marineleisure.member.dto.LoginResponse; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private OauthService oauthService; + + @Mock + private CookieUtil cookieUtil; + + @Mock + private StateEncryptionUtil stateEncryptionUtil; + + @InjectMocks + private AuthService authService; + + private Member testMember; + private HttpServletResponse mockResponse; + private Cookie mockCookie; + + @BeforeEach + void setUp() { + // 테스트용 Member 객체 생성 + testMember = Member.builder() + .nickname("testUser") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .build(); + + // ID 설정 (리플렉션 사용) + ReflectionTestUtils.setField(testMember, "id", 1L); + + // Mock HttpServletResponse + mockResponse = mock(HttpServletResponse.class); + + // Mock Cookie + mockCookie = mock(Cookie.class); + + // useCookie 설정 (기본값: true) + ReflectionTestUtils.setField(authService, "useCookie", true); + } + + @Test + @DisplayName("카카오 로그인을 처리하고 로그인 응답을 반환할 수 있다 (쿠키 모드)") + void processKakaoLogin() { + // given + String code = "test-auth-code"; + String state = "test-state"; + String encryptedState = "encrypted-test-state"; + String accessToken = "kakao-access-token"; + String jwtAccessToken = "jwt-access-token"; + String refreshToken = "jwt-refresh-token"; + + // useCookie = true 설정 (기본값) + ReflectionTestUtils.setField(authService, "useCookie", true); + + // 카카오 토큰 응답 설정 + KakaoTokenResponse tokenResponse = KakaoTokenResponse.builder() + .accessToken(accessToken) + .tokenType("bearer") + .refreshToken("kakao-refresh-token") + .expiresIn(3600L) + .build(); + + // 쿠키 설정 + when(cookieUtil.createRefreshTokenCookie(refreshToken)).thenReturn(mockCookie); + + // state 검증 모킹 + when(stateEncryptionUtil.validateState(state, encryptedState)).thenReturn(true); + + // 서비스 메서드 모킹 + when(oauthService.exchangeCodeForToken(code)).thenReturn(tokenResponse); + when(oauthService.processKakaoUser(accessToken)).thenReturn(testMember); + // findUserById는 이제 필요 없음 (processKakaoUser가 직접 Member를 반환) + when(jwtTokenProvider.createAccessToken(testMember)).thenReturn(jwtAccessToken); + when(jwtTokenProvider.createRefreshToken(testMember)).thenReturn(refreshToken); + + // when + LoginResponse response = authService.processKakaoLogin(code, state, encryptedState, mockResponse); + + // then + assertThat(response).isNotNull(); + assertThat(response.accessToken()).isEqualTo(jwtAccessToken); + assertThat(response.userId()).isEqualTo(1L); + assertThat(response.email()).isEqualTo("test@example.com"); + assertThat(response.nickname()).isEqualTo("testUser"); + assertThat(response.refreshToken()).isNull(); // 쿠키 모드에서는 refreshToken이 응답에 포함되지 않음 + + // 쿠키 추가 확인 + verify(cookieUtil).addCookie(mockResponse, mockCookie); + } + + @Test + @DisplayName("카카오 로그인을 처리하고 로그인 응답을 반환할 수 있다 (비쿠키 모드)") + void processKakaoLogin_noCookie() { + // given + String code = "test-auth-code"; + String state = "test-state"; + String encryptedState = "encrypted-test-state"; + String accessToken = "kakao-access-token"; + String jwtAccessToken = "jwt-access-token"; + String refreshToken = "jwt-refresh-token"; + + // useCookie = false 설정 + ReflectionTestUtils.setField(authService, "useCookie", false); + + // 카카오 토큰 응답 설정 + KakaoTokenResponse tokenResponse = KakaoTokenResponse.builder() + .accessToken(accessToken) + .tokenType("bearer") + .refreshToken("kakao-refresh-token") + .expiresIn(3600L) + .build(); + + // state 검증 모킹 + when(stateEncryptionUtil.validateState(state, encryptedState)).thenReturn(true); + + // 서비스 메서드 모킹 + when(oauthService.exchangeCodeForToken(code)).thenReturn(tokenResponse); + when(oauthService.processKakaoUser(accessToken)).thenReturn(testMember); + when(jwtTokenProvider.createAccessToken(testMember)).thenReturn(jwtAccessToken); + when(jwtTokenProvider.createRefreshToken(testMember)).thenReturn(refreshToken); + + // when + LoginResponse response = authService.processKakaoLogin(code, state, encryptedState, mockResponse); + + // then + assertThat(response).isNotNull(); + assertThat(response.accessToken()).isEqualTo(jwtAccessToken); + assertThat(response.userId()).isEqualTo(1L); + assertThat(response.email()).isEqualTo("test@example.com"); + assertThat(response.nickname()).isEqualTo("testUser"); + assertThat(response.refreshToken()).isEqualTo(refreshToken); // 비쿠키 모드에서는 refreshToken이 응답에 포함됨 + + // 쿠키 추가되지 않음 확인 + verify(cookieUtil, never()).addCookie(any(), any()); + } + + @Test + @DisplayName("카카오 액세스 토큰이 없으면 예외가 발생한다") + void processKakaoLogin_noAccessToken() { + // given + String code = "test-auth-code"; + String state = "test-state"; + String encryptedState = "encrypted-test-state"; + + // 액세스 토큰이 없는 응답 설정 + KakaoTokenResponse tokenResponse = KakaoTokenResponse.builder() + .accessToken(null) + .tokenType("bearer") + .refreshToken("kakao-refresh-token") + .expiresIn(3600L) + .build(); + + // state 검증 모킹 + when(stateEncryptionUtil.validateState(state, encryptedState)).thenReturn(true); + + when(oauthService.exchangeCodeForToken(code)).thenReturn(tokenResponse); + + // when & then + assertThatThrownBy(() -> authService.processKakaoLogin(code, state, encryptedState, mockResponse)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to get access token from Kakao"); + } + + @Test + @DisplayName("리프레시 토큰으로 새 토큰을 발급할 수 있다 (쿠키 모드)") + void refreshToken() { + // given + String refreshToken = "valid-refresh-token"; + String newAccessToken = "new-access-token"; + String newRefreshToken = "new-refresh-token"; + + // useCookie = true 설정 (기본값) + ReflectionTestUtils.setField(authService, "useCookie", true); + + // 쿠키 설정 + when(cookieUtil.createRefreshTokenCookie(newRefreshToken)).thenReturn(mockCookie); + + // 토큰 검증 및 생성 설정 + when(jwtTokenProvider.validateRefreshToken(refreshToken)).thenReturn(true); + when(jwtTokenProvider.getMemberId(refreshToken)).thenReturn(1L); + when(oauthService.findUserById(1L)).thenReturn(testMember); + when(jwtTokenProvider.createAccessToken(testMember)).thenReturn(newAccessToken); + when(jwtTokenProvider.createRefreshToken(testMember)).thenReturn(newRefreshToken); + + // when + LoginResponse response = authService.refreshToken(refreshToken, mockResponse); + + // then + assertThat(response).isNotNull(); + assertThat(response.accessToken()).isEqualTo(newAccessToken); + assertThat(response.userId()).isEqualTo(1L); + assertThat(response.email()).isEqualTo("test@example.com"); + assertThat(response.nickname()).isEqualTo("testUser"); + assertThat(response.refreshToken()).isNull(); // 쿠키 모드에서는 refreshToken이 응답에 포함되지 않음 + + // 기존 토큰 블랙리스트 추가 확인 + verify(jwtTokenProvider).blacklistRefreshToken(refreshToken); + + // 새 쿠키 추가 확인 + verify(cookieUtil).addCookie(mockResponse, mockCookie); + } + + @Test + @DisplayName("리프레시 토큰으로 새 토큰을 발급할 수 있다 (비쿠키 모드)") + void refreshToken_noCookie() { + // given + String refreshToken = "valid-refresh-token"; + String newAccessToken = "new-access-token"; + String newRefreshToken = "new-refresh-token"; + + // useCookie = false 설정 + ReflectionTestUtils.setField(authService, "useCookie", false); + + // 토큰 검증 및 생성 설정 + when(jwtTokenProvider.validateRefreshToken(refreshToken)).thenReturn(true); + when(jwtTokenProvider.getMemberId(refreshToken)).thenReturn(1L); + when(oauthService.findUserById(1L)).thenReturn(testMember); + when(jwtTokenProvider.createAccessToken(testMember)).thenReturn(newAccessToken); + when(jwtTokenProvider.createRefreshToken(testMember)).thenReturn(newRefreshToken); + + // when + LoginResponse response = authService.refreshToken(refreshToken, mockResponse); + + // then + assertThat(response).isNotNull(); + assertThat(response.accessToken()).isEqualTo(newAccessToken); + assertThat(response.userId()).isEqualTo(1L); + assertThat(response.email()).isEqualTo("test@example.com"); + assertThat(response.nickname()).isEqualTo("testUser"); + assertThat(response.refreshToken()).isEqualTo(newRefreshToken); // 비쿠키 모드에서는 refreshToken이 응답에 포함됨 + + // 기존 토큰 블랙리스트 추가 확인 + verify(jwtTokenProvider).blacklistRefreshToken(refreshToken); + + // 쿠키 추가되지 않음 확인 + verify(cookieUtil, never()).addCookie(any(), any()); + } + + @Test + @DisplayName("빈 리프레시 토큰으로 토큰 재발급 시 예외가 발생한다") + void refreshToken_emptyToken() { + // given + String refreshToken = ""; + + // when & then + assertThatThrownBy(() -> authService.refreshToken(refreshToken, mockResponse)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("리프레시 토큰이 없습니다"); + } + + @Test + @DisplayName("유효하지 않은 리프레시 토큰으로 토큰 재발급 시 예외가 발생한다") + void refreshToken_invalidToken() { + // given + String refreshToken = "invalid-refresh-token"; + + // 토큰 검증 실패 설정 + when(jwtTokenProvider.validateRefreshToken(refreshToken)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> authService.refreshToken(refreshToken, mockResponse)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 리프레시 토큰입니다"); + } + + @Test + @DisplayName("로그아웃 시 리프레시 토큰을 블랙리스트에 추가하고 쿠키를 삭제한다") + void logout() { + // given + String refreshToken = "valid-refresh-token"; + + // 쿠키 삭제 설정 + when(cookieUtil.deleteRefreshTokenCookie()).thenReturn(mockCookie); + + // when + authService.logout(refreshToken, mockResponse); + + // then + // 토큰 블랙리스트 추가 확인 + verify(jwtTokenProvider).blacklistRefreshToken(refreshToken); + + // 쿠키 삭제 확인 + verify(cookieUtil).addCookie(mockResponse, mockCookie); + } + + @Test + @DisplayName("빈 리프레시 토큰으로 로그아웃 시 블랙리스트에 추가하지 않고 쿠키만 삭제한다") + void logout_emptyToken() { + // given + String refreshToken = ""; + + // 쿠키 삭제 설정 + when(cookieUtil.deleteRefreshTokenCookie()).thenReturn(mockCookie); + + // when + authService.logout(refreshToken, mockResponse); + + // then + // 토큰 블랙리스트 추가하지 않음 + verify(jwtTokenProvider, never()).blacklistRefreshToken(anyString()); + + // 쿠키 삭제 확인 + verify(cookieUtil).addCookie(mockResponse, mockCookie); + } +} diff --git a/src/test/java/sevenstar/marineleisure/member/service/MemberServiceTest.java b/src/test/java/sevenstar/marineleisure/member/service/MemberServiceTest.java new file mode 100644 index 00000000..c3d0d096 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/member/service/MemberServiceTest.java @@ -0,0 +1,214 @@ +package sevenstar.marineleisure.member.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import sevenstar.marineleisure.global.enums.MemberStatus; +import sevenstar.marineleisure.global.exception.CustomException; +import sevenstar.marineleisure.global.exception.enums.MemberErrorCode; +import sevenstar.marineleisure.meeting.domain.Meeting; +import sevenstar.marineleisure.meeting.domain.Participant; +import sevenstar.marineleisure.meeting.repository.MeetingRepository; +import sevenstar.marineleisure.meeting.repository.ParticipantRepository; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.dto.MemberDetailResponse; +import sevenstar.marineleisure.member.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private MeetingRepository meetingRepository; + + @Mock + private ParticipantRepository participantRepository; + + @InjectMocks + private MemberService memberService; + + private Member testMember; + private Long memberId = 1L; + + @BeforeEach + void setUp() { + // 테스트용 Member 객체 생성 + testMember = Member.builder() + .nickname("testUser") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .latitude(BigDecimal.valueOf(37.5665)) + .longitude(BigDecimal.valueOf(126.9780)) + .build(); + + // ID 설정 (리플렉션 사용) + ReflectionTestUtils.setField(testMember, "id", memberId); + ReflectionTestUtils.setField(testMember, "status", MemberStatus.ACTIVE); + } + + @Test + @DisplayName("회원 ID로 회원 상세 정보를 조회할 수 있다") + void getMemberDetail() { + // given + when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember)); + + // when + MemberDetailResponse response = memberService.getMemberDetail(memberId); + + // then + assertThat(response).isNotNull(); + assertThat(response.getId()).isEqualTo(memberId); + assertThat(response.getEmail()).isEqualTo("test@example.com"); + assertThat(response.getNickname()).isEqualTo("testUser"); + assertThat(response.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(response.getLatitude()).isEqualTo(BigDecimal.valueOf(37.5665)); + assertThat(response.getLongitude()).isEqualTo(BigDecimal.valueOf(126.9780)); + } + + @Test + @DisplayName("존재하지 않는 회원 ID로 조회 시 예외가 발생한다") + void getMemberDetail_memberNotFound() { + // given + Long nonExistentMemberId = 999L; + when(memberRepository.findById(nonExistentMemberId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberService.getMemberDetail(nonExistentMemberId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("현재 로그인한 회원의 상세 정보를 조회할 수 있다") + void getCurrentMemberDetail() { + // given + when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember)); + + // when + MemberDetailResponse response = memberService.getCurrentMemberDetail(memberId); + + // then + assertThat(response).isNotNull(); + assertThat(response.getId()).isEqualTo(memberId); + assertThat(response.getEmail()).isEqualTo("test@example.com"); + assertThat(response.getNickname()).isEqualTo("testUser"); + assertThat(response.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(response.getLatitude()).isEqualTo(BigDecimal.valueOf(37.5665)); + assertThat(response.getLongitude()).isEqualTo(BigDecimal.valueOf(126.9780)); + } + + @Test + @DisplayName("존재하지 않는 회원 ID로 현재 회원 조회 시 예외가 발생한다") + void getCurrentMemberDetail_memberNotFound() { + // given + Long nonExistentMemberId = 999L; + when(memberRepository.findById(nonExistentMemberId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberService.getCurrentMemberDetail(nonExistentMemberId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("회원의 닉네임을 업데이트할 수 있다") + void updateMemberNickname() { + // given + String newNickname = "newNickname"; + when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember)); + when(memberRepository.save(any(Member.class))).thenReturn(testMember); + + // when + MemberDetailResponse response = memberService.updateMemberNickname(memberId, newNickname); + + // then + assertThat(response).isNotNull(); + assertThat(response.getNickname()).isEqualTo(newNickname); + verify(memberRepository).findById(memberId); + verify(memberRepository).save(testMember); + } + + @Test + @DisplayName("회원의 위치 정보를 업데이트할 수 있다") + void updateMemberLocation() { + // given + BigDecimal newLatitude = BigDecimal.valueOf(35.1234); + BigDecimal newLongitude = BigDecimal.valueOf(129.5678); + when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember)); + when(memberRepository.save(any(Member.class))).thenReturn(testMember); + + // when + MemberDetailResponse response = memberService.updateMemberLocation(memberId, newLatitude, newLongitude); + + // then + assertThat(response).isNotNull(); + // Note: We can't directly verify the latitude and longitude values here because + // the test member's fields are updated through reflection in the service method + verify(memberRepository).findById(memberId); + verify(memberRepository).save(testMember); + } + + @Test + @DisplayName("회원의 상태를 업데이트할 수 있다") + void updateMemberStatus() { + // given + MemberStatus newStatus = MemberStatus.EXPIRED; + when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember)); + when(memberRepository.save(any(Member.class))).thenReturn(testMember); + + // when + MemberDetailResponse response = memberService.updateMemberStatus(memberId, newStatus); + + // then + assertThat(response).isNotNull(); + // Note: We can't directly verify the status value here because + // the test member's field is updated through reflection in the service method + verify(memberRepository).findById(memberId); + verify(memberRepository).save(testMember); + } + + @Test + @DisplayName("회원을 탈퇴 처리할 수 있다") + void deleteMember() { + // given + List hostedMeetings = new ArrayList<>(); + Meeting mockMeeting = mock(Meeting.class); + hostedMeetings.add(mockMeeting); + + List participations = new ArrayList<>(); + Participant mockParticipant = mock(Participant.class); + participations.add(mockParticipant); + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember)); + when(meetingRepository.findByHostId(memberId)).thenReturn(hostedMeetings); + when(participantRepository.findByUserId(memberId)).thenReturn(participations); + + // when + memberService.deleteMember(memberId); + + // then + verify(memberRepository).findById(memberId); + verify(meetingRepository).findByHostId(memberId); + verify(meetingRepository).deleteAll(hostedMeetings); + verify(participantRepository).findByUserId(memberId); + verify(participantRepository).deleteAll(participations); + verify(memberRepository).save(testMember); + } +} diff --git a/src/test/java/sevenstar/marineleisure/member/service/OauthServiceTest.java b/src/test/java/sevenstar/marineleisure/member/service/OauthServiceTest.java new file mode 100644 index 00000000..8d62cd84 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/member/service/OauthServiceTest.java @@ -0,0 +1,280 @@ +package sevenstar.marineleisure.member.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.lenient; + +import java.util.HashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.WebClient; + +import reactor.core.publisher.Mono; +import sevenstar.marineleisure.global.util.StateEncryptionUtil; +import sevenstar.marineleisure.member.domain.Member; +import sevenstar.marineleisure.member.dto.KakaoTokenResponse; +import sevenstar.marineleisure.member.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +class OauthServiceTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private WebClient webClient; + + @Mock + private StateEncryptionUtil stateEncryptionUtil; + + @InjectMocks + private OauthService oauthService; + + @BeforeEach + void setUp() { + // 필요한 프로퍼티 설정 + ReflectionTestUtils.setField(oauthService, "apiKey", "test-api-key"); + ReflectionTestUtils.setField(oauthService, "clientSecret", "test-client-secret"); + ReflectionTestUtils.setField(oauthService, "kakaoBaseUri", "https://kauth.kakao.com"); + ReflectionTestUtils.setField(oauthService, "redirectUri", "http://localhost:8080/oauth/kakao/code"); + + // StateEncryptionUtil 모킹 (lenient 설정으로 불필요한 stubbing 경고 방지) + lenient().when(stateEncryptionUtil.encryptState(anyString())).thenReturn("encrypted-state"); + lenient().when(stateEncryptionUtil.validateState(anyString(), anyString())).thenReturn(true); + } + + @Test + @DisplayName("카카오 로그인 URL을 생성할 수 있다") + void getKakaoLoginUrl() { + // when + Map result = oauthService.getKakaoLoginUrl(null); + + // then + assertThat(result).containsKey("kakaoAuthUrl"); + assertThat(result).containsKey("state"); + assertThat(result).containsKey("encryptedState"); + assertThat(result.get("encryptedState")).isEqualTo("encrypted-state"); + assertThat(result.get("kakaoAuthUrl")).contains("https://kauth.kakao.com/oauth/authorize"); + assertThat(result.get("kakaoAuthUrl")).contains("client_id=test-api-key"); + assertThat(result.get("kakaoAuthUrl")).contains("redirect_uri=http://localhost:8080/oauth/kakao/code"); + assertThat(result.get("kakaoAuthUrl")).contains("response_type=code"); + assertThat(result.get("kakaoAuthUrl")).contains("state=" + result.get("state")); + } + + @Test + @DisplayName("커스텀 리다이렉트 URI로 카카오 로그인 URL을 생성할 수 있다") + void getKakaoLoginUrlWithCustomRedirectUri() { + // given + String customRedirectUri = "http://custom-redirect.com/callback"; + + // when + Map result = oauthService.getKakaoLoginUrl(customRedirectUri); + + // then + assertThat(result).containsKey("kakaoAuthUrl"); + assertThat(result).containsKey("state"); + assertThat(result).containsKey("encryptedState"); + assertThat(result.get("encryptedState")).isEqualTo("encrypted-state"); + assertThat(result.get("kakaoAuthUrl")).contains("redirect_uri=" + customRedirectUri); + } + + @Test + @DisplayName("인증 코드로 카카오 토큰을 교환할 수 있다") + void exchangeCodeForToken() { + // given + String code = "test-auth-code"; + KakaoTokenResponse expectedResponse = KakaoTokenResponse.builder() + .accessToken("test-access-token") + .tokenType("bearer") + .refreshToken("test-refresh-token") + .expiresIn(3600L) + .scope("profile") + .refreshTokenExpiresIn(86400L) + .build(); + + // WebClient 모킹 + WebClient.RequestHeadersUriSpec requestHeadersUriSpec = mock(WebClient.RequestHeadersUriSpec.class); + WebClient.RequestBodyUriSpec requestBodyUriSpec = mock(WebClient.RequestBodyUriSpec.class); + WebClient.RequestBodySpec requestBodySpec = mock(WebClient.RequestBodySpec.class); + WebClient.RequestHeadersSpec requestHeadersSpec = mock(WebClient.RequestHeadersSpec.class); + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + + when(webClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.uri(anyString())).thenReturn(requestBodySpec); + when(requestBodySpec.header(anyString(), anyString())).thenReturn(requestBodySpec); + when(requestBodySpec.body(any())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(KakaoTokenResponse.class)).thenReturn(Mono.just(expectedResponse)); + + // when + KakaoTokenResponse result = oauthService.exchangeCodeForToken(code); + + // then + assertThat(result).isNotNull(); + assertThat(result.accessToken()).isEqualTo("test-access-token"); + assertThat(result.refreshToken()).isEqualTo("test-refresh-token"); + } + + @Test + @DisplayName("카카오 사용자 정보를 처리하고 회원 정보를 반환할 수 있다") + void processKakaoUser() { + // given + String accessToken = "test-access-token"; + Map userInfo = new HashMap<>(); + userInfo.put("id", 12345L); + + Map kakaoAccount = new HashMap<>(); + Map profile = new HashMap<>(); + profile.put("nickname", "testUser"); + kakaoAccount.put("profile", profile); + kakaoAccount.put("email", "test@example.com"); + userInfo.put("kakao_account", kakaoAccount); + + Member member = Member.builder() + .nickname("testUser") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .build(); + + // ID 설정 (리플렉션 사용) + ReflectionTestUtils.setField(member, "id", 1L); + + // WebClient 모킹 - 간소화된 방식 + WebClient.RequestHeadersUriSpec requestHeadersUriSpec = mock(WebClient.RequestHeadersUriSpec.class); + WebClient.RequestHeadersSpec requestHeadersSpec = mock(WebClient.RequestHeadersSpec.class); + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + + when(webClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.header(anyString(), anyString())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(any(ParameterizedTypeReference.class))).thenReturn(Mono.just(userInfo)); + + // MemberRepository 모킹 + when(memberRepository.findByProviderAndProviderId(eq("kakao"), eq("12345"))) + .thenReturn(Optional.empty()); + when(memberRepository.save(any(Member.class))).thenReturn(member); + + // when + Member result = oauthService.processKakaoUser(accessToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(1L); + assertThat(result.getEmail()).isEqualTo("test@example.com"); + assertThat(result.getNickname()).isEqualTo("testUser"); + } + + @Test + @DisplayName("기존 회원이 있는 경우 닉네임을 업데이트하고 회원 정보를 반환할 수 있다") + void processKakaoUserWithExistingMember() { + // given + String accessToken = "test-access-token"; + Map userInfo = new HashMap<>(); + userInfo.put("id", 12345L); + + Map kakaoAccount = new HashMap<>(); + Map profile = new HashMap<>(); + profile.put("nickname", "newNickname"); + kakaoAccount.put("profile", profile); + kakaoAccount.put("email", "test@example.com"); + userInfo.put("kakao_account", kakaoAccount); + + Member existingMember = Member.builder() + .nickname("oldNickname") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .build(); + + // ID 설정 (리플렉션 사용) + ReflectionTestUtils.setField(existingMember, "id", 1L); + + existingMember.updateNickname("newNickname"); + Member updatedMember = existingMember; + + // WebClient 모킹 - 간소화된 방식 + WebClient.RequestHeadersUriSpec requestHeadersUriSpec = mock(WebClient.RequestHeadersUriSpec.class); + WebClient.RequestHeadersSpec requestHeadersSpec = mock(WebClient.RequestHeadersSpec.class); + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + + when(webClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.header(anyString(), anyString())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(any(ParameterizedTypeReference.class))).thenReturn(Mono.just(userInfo)); + + // MemberRepository 모킹 + when(memberRepository.findByProviderAndProviderId(eq("kakao"), eq("12345"))) + .thenReturn(Optional.of(existingMember)); + when(memberRepository.save(any(Member.class))).thenReturn(updatedMember); + + // when + Member result = oauthService.processKakaoUser(accessToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(1L); + assertThat(result.getEmail()).isEqualTo("test@example.com"); + assertThat(result.getNickname()).isEqualTo("newNickname"); + } + + @Test + @DisplayName("ID로 회원을 찾을 수 있다") + void findUserById() { + // given + Long memberId = 1L; + Member member = Member.builder() + .nickname("testUser") + .email("test@example.com") + .provider("kakao") + .providerId("12345") + .build(); + + // ID 설정 (리플렉션 사용) + ReflectionTestUtils.setField(member, "id", memberId); + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + + // when + Member result = oauthService.findUserById(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(memberId); + assertThat(result.getNickname()).isEqualTo("testUser"); + assertThat(result.getEmail()).isEqualTo("test@example.com"); + + // verify + verify(memberRepository).findById(memberId); + } + + @Test + @DisplayName("존재하지 않는 ID로 회원을 찾으면 예외가 발생한다") + void findUserByIdNotFound() { + // given + Long memberId = 999L; + when(memberRepository.findById(memberId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> oauthService.findUserById(memberId)) + .isInstanceOf(NoSuchElementException.class) + .hasMessageContaining("User not found for id: " + memberId); + + // verify + verify(memberRepository).findById(memberId); + } +} diff --git a/src/test/java/sevenstar/marineleisure/spot/service/SpotServiceTest.java b/src/test/java/sevenstar/marineleisure/spot/service/SpotServiceTest.java new file mode 100644 index 00000000..d9caa778 --- /dev/null +++ b/src/test/java/sevenstar/marineleisure/spot/service/SpotServiceTest.java @@ -0,0 +1,150 @@ +package sevenstar.marineleisure.spot.service; + +import static org.assertj.core.api.Assertions.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +import sevenstar.marineleisure.annotation.MysqlDataJpaTest; +import sevenstar.marineleisure.forecast.domain.Fishing; +import sevenstar.marineleisure.forecast.domain.FishingTarget; +import sevenstar.marineleisure.forecast.domain.Mudflat; +import sevenstar.marineleisure.forecast.domain.Scuba; +import sevenstar.marineleisure.forecast.domain.Surfing; +import sevenstar.marineleisure.forecast.repository.FishingRepository; +import sevenstar.marineleisure.forecast.repository.FishingTargetRepository; +import sevenstar.marineleisure.forecast.repository.MudflatRepository; +import sevenstar.marineleisure.forecast.repository.ScubaRepository; +import sevenstar.marineleisure.forecast.repository.SurfingRepository; +import sevenstar.marineleisure.global.enums.ActivityCategory; +import sevenstar.marineleisure.global.enums.TidePhase; +import sevenstar.marineleisure.global.enums.TimePeriod; +import sevenstar.marineleisure.global.enums.TotalIndex; +import sevenstar.marineleisure.global.utils.GeoUtils; +import sevenstar.marineleisure.spot.config.GeoConfig; +import sevenstar.marineleisure.spot.domain.OutdoorSpot; +import sevenstar.marineleisure.spot.dto.SpotReadResponse; +import sevenstar.marineleisure.spot.dto.detail.provider.ActivityDetailProviderFactory; +import sevenstar.marineleisure.spot.dto.detail.provider.FishingDetailProvider; +import sevenstar.marineleisure.spot.dto.detail.provider.MudflatDetailProvider; +import sevenstar.marineleisure.spot.dto.detail.provider.ScubaDetailProvider; +import sevenstar.marineleisure.spot.dto.detail.provider.SurfingDetailProvider; +import sevenstar.marineleisure.spot.repository.OutdoorSpotRepository; + +@MysqlDataJpaTest +@Import({SpotServiceImpl.class, GeoUtils.class, GeoConfig.class, ActivityDetailProviderFactory.class, + FishingDetailProvider.class, MudflatDetailProvider.class, ScubaDetailProvider.class, + SurfingDetailProvider.class}) +class SpotServiceTest { + @Autowired + private SpotService spotService; + @Autowired + private OutdoorSpotRepository outdoorSpotRepository; + @Autowired + private FishingRepository fishingRepository; + @Autowired + private FishingTargetRepository fishingTargetRepository; + @Autowired + private ScubaRepository scubaRepository; + @Autowired + private MudflatRepository mudflatRepository; + @Autowired + private SurfingRepository surfingRepository; + @Autowired + private GeoUtils geoUtils; + + private float baseLat = 40.7128f; + private float baseLon = 74.0060f; + + @BeforeEach + void setUp() { + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(7); + FishingTarget target = fishingTargetRepository.save(new FishingTarget("Temp1")); + + for (ActivityCategory category : List.of(ActivityCategory.FISHING, ActivityCategory.MUDFLAT, + ActivityCategory.SURFING, ActivityCategory.SCUBA)) { + + // 0.001 ~ 0.005 사이 랜덤한 변화값 생성 + float latOffset = (float)((Math.random() - 0.5) * 0.01); // ±0.005 + float lonOffset = (float)((Math.random() - 0.5) * 0.01); // ±0.005 + + BigDecimal latitude = BigDecimal.valueOf(baseLat + latOffset); + BigDecimal longitude = BigDecimal.valueOf(baseLon + lonOffset); + OutdoorSpot outdoorSpot = outdoorSpotRepository.save(OutdoorSpot.builder() + .latitude(latitude) + .longitude(longitude) + .location("뉴욕 강남구") + .name("뉴욕 강남구") + .category(category) + .point(geoUtils.createPoint(latitude, longitude)) + .build()); + + if (category == ActivityCategory.FISHING) { + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + fishingRepository.save(Fishing.builder() + .spotId(outdoorSpot.getId()) + .targetId(target.getId()) + .forecastDate(date) + .timePeriod(TimePeriod.AM) + .tide(TidePhase.SPRING_TIDE) + .totalIndex(TotalIndex.GOOD) + .build()); + } + } else if (category == ActivityCategory.SCUBA) { + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + scubaRepository.save(Scuba.builder() + .spotId(outdoorSpot.getId()) + .forecastDate(date) + .timePeriod(TimePeriod.AM) + .tide(TidePhase.SPRING_TIDE) + .totalIndex(TotalIndex.GOOD) + .build()); + } + } else if (category == ActivityCategory.SURFING) { + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + surfingRepository.save(Surfing.builder() + .spotId(outdoorSpot.getId()) + .forecastDate(date) + .timePeriod(TimePeriod.AM) + .totalIndex(TotalIndex.GOOD) + .build()); + } + } else if (category == ActivityCategory.MUDFLAT) { + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + mudflatRepository.save(Mudflat.builder() + .spotId(outdoorSpot.getId()) + .forecastDate(date) + .totalIndex(TotalIndex.GOOD) + .build()); + } + + } + + } + } + + @Test + void should_searchSpot_when_givenLatitudeAndLongitudeAndActivityCategory() { + // given + Integer radius = 1; + // when + SpotReadResponse fishingResponse = spotService.searchSpot(baseLat, baseLon, radius, ActivityCategory.FISHING); + SpotReadResponse scubaResponse = spotService.searchSpot(baseLat, baseLon, radius, ActivityCategory.SCUBA); + SpotReadResponse surfingResponse = spotService.searchSpot(baseLat, baseLon, radius, ActivityCategory.SURFING); + SpotReadResponse mudflatResponse = spotService.searchSpot(baseLat, baseLon, radius, ActivityCategory.MUDFLAT); + + // then + assertThat(fishingResponse.spots()).hasSize(1); + assertThat(scubaResponse.spots()).hasSize(1); + assertThat(surfingResponse.spots()).hasSize(1); + assertThat(mudflatResponse.spots()).hasSize(1); + } + +} \ No newline at end of file