diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..feb483d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: 기능 추가 템플릿 +title: '' +labels: '' +assignees: '' + +--- + +## 어떤 기능인가요? + +> 추가하려는 기능에 대해 간결하게 설명해주세요 + +## 작업 상세 내용 + +- [ ] TODO +- [ ] TODO +- [ ] TODO + +## 참고할만한 자료(선택) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..be817f6 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,65 @@ +name: Deploy to Development Environment + +on: + push: + branches: [ develop ] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'adopt' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew clean -x test build + + - name: Rename JAR for deployment + run: cp build/libs/chalpu-0.0.1-SNAPSHOT.jar app.jar + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-dev-role + aws-region: ap-northeast-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag, and push image to Amazon ECR + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: chalpu-dev-app + IMAGE_TAG: dev-${{ github.sha }} + run: | + docker build --platform linux/amd64 -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:dev-latest + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:dev-latest + + - name: Update ECS service + env: + ECS_CLUSTER: chalpu-ecs-cluster + ECS_SERVICE: chalpu-dev-app-service + run: | + aws ecs update-service \ + --cluster $ECS_CLUSTER \ + --service $ECS_SERVICE \ + --force-new-deployment \ + --region ap-northeast-2 \ No newline at end of file diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..92366aa --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,68 @@ +name: Deploy to Production Environment + +on: + push: + branches: [ main ] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'adopt' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Rename JAR for deployment + run: cp build/libs/chalpu-0.0.1-SNAPSHOT.jar app.jar + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-prod-role + aws-region: ap-northeast-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag, and push image to Amazon ECR + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: chalpu-dev-app + IMAGE_TAG: prod-${{ github.sha }} + run: | + docker build --platform linux/amd64 -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:prod-latest + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:prod-latest + + - name: Update ECS service + env: + ECS_CLUSTER: chalpu-ecs-prod-cluster + ECS_SERVICE: chalpu-prod-app-service + run: | + aws ecs update-service \ + --cluster $ECS_CLUSTER \ + --service $ECS_SERVICE \ + --force-new-deployment \ + --region ap-northeast-2 \ No newline at end of file diff --git a/.github/workflows/slack-merge-notify.yml b/.github/workflows/slack-merge-notify.yml index ca3614b..d52a111 100644 --- a/.github/workflows/slack-merge-notify.yml +++ b/.github/workflows/slack-merge-notify.yml @@ -6,7 +6,7 @@ on: push: branches: - main - - dev # 필요 시 여기에 사용 중인 브랜치 추가 + - develop jobs: notify: @@ -15,13 +15,17 @@ jobs: - name: Send Slack Message (PR Merge or Direct Push) run: | if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.merged }}" = "true" ]; then + # PR이 머지되었을 때만 실행 curl -X POST -H 'Content-type: application/json' \ --data '{ "text": "✔️ PR #${{ github.event.pull_request.number }}이 `${{ github.event.pull_request.base.ref }}` 브랜치에 머지되었습니다.\n머지한 사람: ${{ github.actor }}\n<${{ github.event.pull_request.html_url }}|PR 보러가기>" }' ${{ secrets.SLACK_WEBHOOK_URL }} elif [ "${{ github.event_name }}" = "push" ]; then - curl -X POST -H 'Content-type: application/json' \ - --data '{ - "text": "✔️ `${{ github.ref_name }}` 브랜치에 커밋이 푸시되었습니다.\n커밋한 사람: ${{ github.actor }}\n" - }' ${{ secrets.SLACK_WEBHOOK_URL }} + # 커밋의 작성자가 'GitHub'가 아닌 경우, 즉 사용자가 직접 푸시한 경우에만 실행 + if [ "${{ github.event.head_commit.committer.name }}" != "GitHub" ]; then + curl -X POST -H 'Content-type: application/json' \ + --data '{ + "text": "✔️ `${{ github.ref_name }}` 브랜치에 커밋이 푸시되었습니다.\n커밋한 사람: ${{ github.actor }}\n" + }' ${{ secrets.SLACK_WEBHOOK_URL }} + fi fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6194d1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +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/ + +lambda-edge/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +application-local.yml + +firebase-service-account.json +# Test utility controller +src/main/java/com/example/chalpu/util/TestUtilController.java + +### Terraform ### +terraform/ +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +terraform.tfvars +*.tfvars +terraform-deploy.sh +deploy.sh +create-parameters.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a3390a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM --platform=linux/amd64 gcr.io/distroless/java17-debian11 + +WORKDIR /app + +COPY build/libs/chalpu-0.0.1-SNAPSHOT.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..e9b510f --- /dev/null +++ b/build.gradle @@ -0,0 +1,66 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-web-services' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // Google API Client for ID Token Verification + implementation 'com.google.api-client:google-api-client:2.0.0' + + // Firebase Admin SDK for FCM + implementation 'com.google.firebase:firebase-admin:9.2.0' + + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.1' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Prometheus 메트릭 수집을 위한 Micrometer + implementation 'io.micrometer:micrometer-registry-prometheus' + + // Loki 로그 전송을 위한 Appender + implementation 'com.github.loki4j:loki-logback-appender:1.5.2' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ff23a68 --- /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 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..d476ae3 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'chalpu' diff --git a/src/main/java/com/example/chalpu/ChalpuApplication.java b/src/main/java/com/example/chalpu/ChalpuApplication.java new file mode 100644 index 0000000..c30fd8f --- /dev/null +++ b/src/main/java/com/example/chalpu/ChalpuApplication.java @@ -0,0 +1,13 @@ +package com.example.chalpu; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ChalpuApplication { + + public static void main(String[] args) { + SpringApplication.run(ChalpuApplication.class, args); + } + +} diff --git a/src/main/java/com/example/chalpu/common/config/JpaAuditingConfig.java b/src/main/java/com/example/chalpu/common/config/JpaAuditingConfig.java new file mode 100644 index 0000000..2feee5c --- /dev/null +++ b/src/main/java/com/example/chalpu/common/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.example.chalpu.common.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/com/example/chalpu/common/entity/BaseTimeEntity.java b/src/main/java/com/example/chalpu/common/entity/BaseTimeEntity.java new file mode 100644 index 0000000..306a86e --- /dev/null +++ b/src/main/java/com/example/chalpu/common/entity/BaseTimeEntity.java @@ -0,0 +1,31 @@ +package com.example.chalpu.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(name = "createdAt", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updatedAt") + private LocalDateTime updatedAt; + + private LocalDateTime deletedAt; + + protected void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/AuthException.java b/src/main/java/com/example/chalpu/common/exception/AuthException.java new file mode 100644 index 0000000..0619990 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/AuthException.java @@ -0,0 +1,11 @@ +package com.example.chalpu.common.exception; + +/** + * 인증 관련 예외 + */ +public class AuthException extends BaseException { + + public AuthException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/BaseException.java b/src/main/java/com/example/chalpu/common/exception/BaseException.java new file mode 100644 index 0000000..7b12e63 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/BaseException.java @@ -0,0 +1,13 @@ +package com.example.chalpu.common.exception; + +import lombok.Getter; + +@Getter +public class BaseException extends RuntimeException { + private final ErrorMessage errorMessage; + + public BaseException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/ErrorMessage.java b/src/main/java/com/example/chalpu/common/exception/ErrorMessage.java new file mode 100644 index 0000000..3b7e8b6 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/ErrorMessage.java @@ -0,0 +1,164 @@ +package com.example.chalpu.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.*; + +@Getter +@AllArgsConstructor +public enum ErrorMessage { + // 유저 관련 에러 + USER_NOT_FOUND(NOT_FOUND, "존재하지 않는 회원입니다."), + USER_EMAIL_ALREADY_EXISTS(BAD_REQUEST, "이미 사용 중인 이메일입니다."), + USER_USERNAME_ALREADY_EXISTS(BAD_REQUEST, "이미 사용 중인 사용자 이름입니다."), + USER_INVALID_CREDENTIALS(UNAUTHORIZED, "잘못된 이메일 또는 비밀번호입니다."), + + // OAuth 관련 에러 + OAUTH_DUPLICATE_EMAIL(BAD_REQUEST, "이미 다른 소셜 계정으로 가입된 이메일입니다."), + OAUTH_USER_INFO_NOT_FOUND(BAD_REQUEST, "OAuth 사용자 정보를 가져올 수 없습니다."), + OAUTH_PROVIDER_NOT_SUPPORTED(BAD_REQUEST, "지원하지 않는 OAuth 제공자입니다."), + OAUTH_AUTHENTICATION_FAILED(UNAUTHORIZED, "OAuth 인증에 실패했습니다."), + + // Apple 관련 에러 + APPLE_IDENTITY_TOKEN_INVALID(UNAUTHORIZED, "Apple Identity Token이 유효하지 않습니다."), + APPLE_IDENTITY_TOKEN_EXPIRED(UNAUTHORIZED, "Apple Identity Token이 만료되었습니다."), + APPLE_PUBLIC_KEY_NOT_FOUND(UNAUTHORIZED, "일치하는 Apple 공개키를 찾을 수 없습니다."), + APPLE_JWT_VERIFICATION_FAILED(UNAUTHORIZED, "Apple JWT 검증에 실패했습니다."), + APPLE_USER_INFO_MISSING(BAD_REQUEST, "Apple 사용자 정보가 누락되었습니다."), + + // 인증 관련 에러 + AUTH_INVALID_TOKEN(UNAUTHORIZED, "유효하지 않은 토큰입니다."), + AUTH_EXPIRED_TOKEN(UNAUTHORIZED, "만료된 토큰입니다."), + AUTH_INVALID_REFRESH_TOKEN(UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."), + AUTH_EXPIRED_REFRESH_TOKEN(UNAUTHORIZED, "만료된 리프레시 토큰입니다."), + AUTH_REFRESH_TOKEN_NOT_FOUND(UNAUTHORIZED, "리프레시 토큰이 없습니다."), + AUTH_TOKEN_NOT_FOUND(UNAUTHORIZED, "인증 토큰이 없습니다."), + AUTH_UNAUTHORIZED(UNAUTHORIZED, "인증되지 않은 사용자입니다."), + AUTH_ACCESS_DENIED(FORBIDDEN, "접근 권한이 없습니다."), + AUTH_HEADER_MISSING(UNAUTHORIZED, "Authorization 헤더가 필요합니다."), + AUTH_INVALID_TOKEN_TYPE(UNAUTHORIZED, "올바르지 않은 토큰 타입입니다."), + + // JWT 관련 에러 + JWT_INVALID_SIGNATURE(UNAUTHORIZED, "JWT 서명이 유효하지 않습니다."), + JWT_MALFORMED(UNAUTHORIZED, "잘못된 형식의 JWT입니다."), + JWT_UNSUPPORTED(UNAUTHORIZED, "지원하지 않는 JWT입니다."), + JWT_CLAIMS_EMPTY(UNAUTHORIZED, "JWT claims가 비어있습니다."), + JWT_EXPIRED(UNAUTHORIZED, "JWT 토큰이 만료되었습니다."), + + // 리프레쉬 토큰 에러 + REFRESH_TOKEN_SAVE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "리프레시 토큰 저장에 실패했습니다."), + REFRESH_TOKEN_DELETE_ERROR(BAD_REQUEST, "토큰 삭제에 실패했습니다."), + + // 매장 관련 에러 + STORE_NOT_FOUND(NOT_FOUND, "매장을 찾을 수 없습니다."), + STORE_ACCESS_DENIED(FORBIDDEN, "매장 접근 권한이 없습니다."), + STORE_CREATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "매장 생성에 실패했습니다."), + STORE_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "매장 정보 수정에 실패했습니다."), + STORE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "매장 삭제에 실패했습니다."), + STORE_OWNER_REQUIRED(FORBIDDEN, "매장 소유자 권한이 필요합니다."), + STORE_MEMBER_NOT_FOUND(NOT_FOUND, "매장 구성원을 찾을 수 없습니다."), + STORE_MEMBER_ALREADY_EXISTS(BAD_REQUEST, "이미 매장 구성원으로 등록되어 있습니다."), + STORE_OWNER_CANNOT_LEAVE(FORBIDDEN, "소유자는 탈퇴할 수 없습니다. 매장을 다른 사람에게 양도하거나 삭제해야 합니다."), + + // 메뉴 관련 에러 + MENU_NOT_FOUND(NOT_FOUND, "메뉴를 찾을 수 없습니다."), + MENU_ACCESS_DENIED(FORBIDDEN, "메뉴 접근 권한이 없습니다."), + MENU_CREATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메뉴 생성에 실패했습니다."), + MENU_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메뉴 수정에 실패했습니다."), + MENU_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메뉴 삭제에 실패했습니다."), + MENU_ITEM_NOT_FOUND(NOT_FOUND, "메뉴 항목을 찾을 수 없습니다."), + MENU_ITEM_CREATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메뉴 항목 생성에 실패했습니다."), + MENU_ITEM_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메뉴 항목 수정에 실패했습니다."), + MENU_ITEM_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메뉴 항목 삭제에 실패했습니다."), + MENU_ITEM_NOT_IN_MENU(BAD_REQUEST, "해당 메뉴에 속한 아이템이 아닙니다."), + + // 음식 관련 에러 + FOOD_NOT_FOUND(NOT_FOUND, "음식을 찾을 수 없습니다."), + FOOD_ACCESS_DENIED(FORBIDDEN, "음식 접근 권한이 없습니다."), + FOOD_CREATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "음식 등록에 실패했습니다."), + FOOD_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "음식 정보 수정에 실패했습니다."), + FOODITEM_NOT_FOUND(NOT_FOUND, "음식 아이템을 찾을 수 없습니다."), + PHOTO_SET_FEATURED_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "대표 사진 설정에 실패했습니다."), + + // 사진 관련 에러 + PRESIGNED_URL_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "사진 업로드를 위한 URL 생성에 실패했습니다."), + PHOTO_REGISTRATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "사진 정보 등록에 실패했습니다."), + PHOTO_NOT_FOUND(NOT_FOUND, "사진을 찾을 수 없습니다."), + PHOTO_ACCESS_DENIED(FORBIDDEN, "사진 접근 권한이 없습니다."), + PHOTO_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "사진 업로드에 실패했습니다."), + PHOTO_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "사진 삭제에 실패했습니다."), + PHOTO_INVALID_FORMAT(BAD_REQUEST, "지원하지 않는 사진 형식입니다."), + PHOTO_SIZE_EXCEEDED(BAD_REQUEST, "사진 크기가 제한을 초과했습니다."), + PHOTO_FEATURE_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "대표 사진 설정에 실패했습니다."), + PHOTO_BACKGROUND_REMOVAL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "배경 제거 처리에 실패했습니다."), + + // 알림 관련 에러 + NOTIFICATION_SERVICE_UNAVAILABLE(HttpStatus.INTERNAL_SERVER_ERROR, "알림 서비스를 사용할 수 없습니다."), + NOTIFICATION_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "알림 전송에 실패했습니다."), + NOTIFICATION_USER_NO_TOKENS(NOT_FOUND, "사용자의 활성화된 토큰이 없습니다."), + NOTIFICATION_TOPIC_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "토픽 알림 전송에 실패했습니다."), + NOTIFICATION_TOKEN_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "토큰 알림 전송에 실패했습니다."), + NOTIFICATION_MULTIPLE_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "다중 알림 전송에 실패했습니다."), + + // 앱 버전 관련 에러 + APP_VERSION_NOT_FOUND(NOT_FOUND, "앱 버전 정보를 찾을 수 없습니다."), + + // 공지사항 관련 에러 + NOTICE_NOT_FOUND(NOT_FOUND, "공지사항을 찾을 수 없습니다."), + NOTICE_INACTIVE(BAD_REQUEST, "비활성화된 공지사항입니다."), + NOTICE_CATEGORY_INVALID(BAD_REQUEST, "잘못된 공지사항 카테고리입니다."), + + // 문의 관련 에러 + INQUIRY_NOT_FOUND(NOT_FOUND, "문의 정보를 찾을 수 없습니다."), + INQUIRY_ACCESS_DENIED(FORBIDDEN, "본인의 문의가 아닙니다."), + INQUIRY_CATEGORY_INVALID(BAD_REQUEST, "잘못된 문의 카테고리입니다."), + INQUIRY_CREATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "문의 등록에 실패했습니다."), + INQUIRY_REPLY_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "문의 답변 등록에 실패했습니다."), + + // FCM 관련 에러 + FCM_TOKEN_INVALID_REQUEST(BAD_REQUEST, "FCM 토큰 요청 데이터가 유효하지 않습니다."), + FCM_TOKEN_NOT_FOUND(NOT_FOUND, "FCM 토큰을 찾을 수 없습니다."), + FCM_TOKEN_REGISTRATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 토큰 등록에 실패했습니다."), + FCM_TOKEN_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 토큰 업데이트에 실패했습니다."), + FCM_TOKEN_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 토큰 삭제에 실패했습니다."), + FCM_DEVICE_TYPE_INVALID(BAD_REQUEST, "지원하지 않는 디바이스 타입입니다."), + FCM_TOKEN_ACCESS_DENIED(FORBIDDEN, "FCM 토큰에 대한 접근 권한이 없습니다."), + FCM_SERVICE_UNAVAILABLE(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 서비스를 사용할 수 없습니다."), + FCM_NOTIFICATION_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "알림 전송에 실패했습니다."), + FCM_NOTIFICATION_INVALID_REQUEST(BAD_REQUEST, "알림 요청 데이터가 유효하지 않습니다."), + FCM_USER_NO_ACTIVE_TOKENS(NOT_FOUND, "사용자의 활성화된 FCM 토큰이 없습니다."), + + // 알림 관련 에러 + NOTIFICATION_FIREBASE_INIT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Firebase 초기화에 실패했습니다."), + + // 공통 에러 + INVALID_REQUEST(BAD_REQUEST, "잘못된 요청입니다."), + INVALID_PARAMETER(BAD_REQUEST, "잘못된 파라미터입니다."), + RESOURCE_NOT_FOUND(NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 HTTP 메서드입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), + UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "예상치 못한 오류가 발생했습니다."), + + // Guide + GUIDE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 가이드를 찾을 수 없습니다."), + SUB_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 하위 카테고리를 찾을 수 없습니다."), + + // S3 + S3_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3 파일 삭제에 실패했습니다."), + + // GuideTag + TAG_ALREADY_EXISTS(HttpStatus.CONFLICT, "해당 가이드에 이미 존재하는 태그입니다."), + GUIDE_TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 가이드-태그 관계를 찾을 수 없습니다."), + + // FCM + FCM_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 메시지 전송에 실패했습니다."), + + // User Store Role + USER_STORE_ROLE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저의 가게 권한을 찾을 수 없습니다."), + USER_DEACTIVATED_REJOIN_UNAVAILABLE(HttpStatus.FORBIDDEN, "탈퇴 후 30일 동안 재가입할 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/example/chalpu/common/exception/FCMException.java b/src/main/java/com/example/chalpu/common/exception/FCMException.java new file mode 100644 index 0000000..157863c --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/FCMException.java @@ -0,0 +1,16 @@ +package com.example.chalpu.common.exception; + +/** + * FCM 관련 예외 클래스 + * FCM 토큰 관리 및 알림 전송 관련 예외를 처리합니다. + * + * @author Backend Team + * @version 1.0 + * @since 2025-06-09 + */ +public class FCMException extends BaseException { + + public FCMException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/FoodException.java b/src/main/java/com/example/chalpu/common/exception/FoodException.java new file mode 100644 index 0000000..dbf7140 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/FoodException.java @@ -0,0 +1,11 @@ +package com.example.chalpu.common.exception; + +/** + * 음식 관련 예외 + */ +public class FoodException extends BaseException { + + public FoodException(ErrorMessage errorMessage) { + super(errorMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/common/exception/GlobalExceptionHandler.java b/src/main/java/com/example/chalpu/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4587bc8 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,170 @@ +package com.example.chalpu.common.exception; + +import com.example.chalpu.common.response.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 커스텀 BaseException 처리 + */ + @ExceptionHandler(BaseException.class) + public ResponseEntity> handleBaseException(BaseException ex) { + ErrorMessage errorMessage = ex.getErrorMessage(); + logger.error("BaseException: {}", errorMessage.getMessage(), ex); + return ResponseEntity.status(errorMessage.getHttpStatus()) + .body(ApiResponse.error(errorMessage.getHttpStatus().value(), errorMessage.getMessage())); + } + + /** + * AuthException 처리 + */ + @ExceptionHandler(AuthException.class) + public ResponseEntity> handleAuthException(AuthException ex) { + ErrorMessage errorMessage = ex.getErrorMessage(); + logger.error("event=auth_exception_handled, error_code={}, error_message={}", + errorMessage.getHttpStatus().value(), errorMessage.getMessage()); + return ResponseEntity.status(errorMessage.getHttpStatus()) + .body(ApiResponse.error(errorMessage.getHttpStatus().value(), errorMessage.getMessage())); + } + + /** + * UserException 처리 + */ + @ExceptionHandler(UserException.class) + public ResponseEntity> handleUserException(UserException ex) { + ErrorMessage errorMessage = ex.getErrorMessage(); + logger.error("UserException: {}", errorMessage.getMessage()); + return ResponseEntity.status(errorMessage.getHttpStatus()) + .body(ApiResponse.error(errorMessage.getHttpStatus().value(), errorMessage.getMessage())); + } + + /** + * OAuthException 처리 + */ + @ExceptionHandler(OAuthException.class) + public ResponseEntity> handleOAuthException(OAuthException ex) { + ErrorMessage errorMessage = ex.getErrorMessage(); + logger.error("OAuthException: {}", errorMessage.getMessage()); + return ResponseEntity.status(errorMessage.getHttpStatus()) + .body(ApiResponse.error(errorMessage.getHttpStatus().value(), errorMessage.getMessage())); + } + + /** + * StoreException 처리 + */ + @ExceptionHandler(StoreException.class) + public ResponseEntity> handleStoreException(StoreException ex) { + ErrorMessage errorMessage = ex.getErrorMessage(); + logger.error("StoreException: {}", errorMessage.getMessage()); + return ResponseEntity.status(errorMessage.getHttpStatus()) + .body(ApiResponse.error(errorMessage.getHttpStatus().value(), errorMessage.getMessage())); + } + + /** + * MenuException 처리 + */ + @ExceptionHandler(MenuException.class) + public ResponseEntity> handleMenuException(MenuException ex) { + ErrorMessage errorMessage = ex.getErrorMessage(); + logger.error("MenuException: {}", errorMessage.getMessage()); + return ResponseEntity.status(errorMessage.getHttpStatus()) + .body(ApiResponse.error(errorMessage.getHttpStatus().value(), errorMessage.getMessage())); + } + + /** + * FoodException 처리 + */ + @ExceptionHandler(FoodException.class) + public ResponseEntity> handleFoodException(FoodException ex) { + ErrorMessage errorMessage = ex.getErrorMessage(); + logger.error("FoodException: {}", errorMessage.getMessage()); + return ResponseEntity.status(errorMessage.getHttpStatus()) + .body(ApiResponse.error(errorMessage.getHttpStatus().value(), errorMessage.getMessage())); + } + + /** + * PhotoException 처리 + */ + @ExceptionHandler(PhotoException.class) + public ResponseEntity> handlePhotoException(PhotoException ex) { + ErrorMessage errorMessage = ex.getErrorMessage(); + logger.error("PhotoException: {}", errorMessage.getMessage()); + return ResponseEntity.status(errorMessage.getHttpStatus()) + .body(ApiResponse.error(errorMessage.getHttpStatus().value(), errorMessage.getMessage())); + } + + /** + * 유효성 검사 실패 예외 처리 + */ + @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) + public ResponseEntity> handleValidationExceptions(Exception ex) { + logger.error("Validation Error: {}", ex.getMessage()); + return ResponseEntity.badRequest() + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "잘못된 요청입니다.")); + } + + /** + * OAuth2AuthenticationProcessingException 처리 + */ + @ExceptionHandler(OAuth2AuthenticationProcessingException.class) + public ResponseEntity> handleOAuth2AuthenticationProcessingException(OAuth2AuthenticationProcessingException ex) { + logger.error("OAuth2AuthenticationProcessingException: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(HttpStatus.UNAUTHORIZED.value(), ex.getMessage())); + } + + /** + * 메소드 인자 타입 불일치 및 파라미터 누락 예외 처리 + */ + @ExceptionHandler({MethodArgumentTypeMismatchException.class, MissingServletRequestParameterException.class, HttpMessageNotReadableException.class}) + public ResponseEntity> handleBadRequestExceptions(Exception ex) { + logger.error("Bad Request: {}", ex.getMessage()); + return ResponseEntity.badRequest() + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "잘못된 요청 형식입니다.")); + } + + /** + * 지원하지 않는 HTTP 메소드 예외 처리 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { + logger.error("Method Not Supported: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ApiResponse.error(HttpStatus.METHOD_NOT_ALLOWED.value(), "지원하지 않는 HTTP 메소드입니다.")); + } + + /** + * RuntimeException 처리 + */ + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException ex) { + logger.error("Runtime Exception: {}", ex.getMessage(), ex); + return ResponseEntity.internalServerError() + .body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다.")); + } + + /** + * 그 외 모든 예외 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGlobalException(Exception ex) { + logger.error("Unexpected Error: ", ex); + return ResponseEntity.internalServerError() + .body(ApiResponse.serverError("예상치 못한 오류가 발생했습니다.")); + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/GuideTagException.java b/src/main/java/com/example/chalpu/common/exception/GuideTagException.java new file mode 100644 index 0000000..9dfd13b --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/GuideTagException.java @@ -0,0 +1,7 @@ +package com.example.chalpu.common.exception; + +public class GuideTagException extends BaseException { + public GuideTagException(ErrorMessage errorMessage) { + super(errorMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/common/exception/MenuException.java b/src/main/java/com/example/chalpu/common/exception/MenuException.java new file mode 100644 index 0000000..89c57d5 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/MenuException.java @@ -0,0 +1,11 @@ +package com.example.chalpu.common.exception; + +/** + * 메뉴 관련 예외 + */ +public class MenuException extends BaseException { + + public MenuException(ErrorMessage errorMessage) { + super(errorMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/common/exception/NoticeException.java b/src/main/java/com/example/chalpu/common/exception/NoticeException.java new file mode 100644 index 0000000..19d9a69 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/NoticeException.java @@ -0,0 +1,7 @@ +package com.example.chalpu.common.exception; + +public class NoticeException extends BaseException { + public NoticeException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/NotificationException.java b/src/main/java/com/example/chalpu/common/exception/NotificationException.java new file mode 100644 index 0000000..f16e445 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/NotificationException.java @@ -0,0 +1,12 @@ +package com.example.chalpu.common.exception; + +/** + * 알림 서비스 관련 예외 클래스 + * FCM 및 푸시 알림 전송 과정에서 발생하는 예외를 처리하기 위한 커스텀 예외 + */ +public class NotificationException extends BaseException { + + public NotificationException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/OAuth2AuthenticationProcessingException.java b/src/main/java/com/example/chalpu/common/exception/OAuth2AuthenticationProcessingException.java new file mode 100644 index 0000000..65393d4 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/OAuth2AuthenticationProcessingException.java @@ -0,0 +1,9 @@ +package com.example.chalpu.common.exception; + +import org.springframework.security.core.AuthenticationException; + +public class OAuth2AuthenticationProcessingException extends AuthenticationException { + public OAuth2AuthenticationProcessingException(String msg) { + super(msg); + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/OAuthException.java b/src/main/java/com/example/chalpu/common/exception/OAuthException.java new file mode 100644 index 0000000..995a0d3 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/OAuthException.java @@ -0,0 +1,11 @@ +package com.example.chalpu.common.exception; + +/** + * OAuth 관련 예외 + */ +public class OAuthException extends BaseException { + + public OAuthException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/PhotoException.java b/src/main/java/com/example/chalpu/common/exception/PhotoException.java new file mode 100644 index 0000000..7473132 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/PhotoException.java @@ -0,0 +1,11 @@ +package com.example.chalpu.common.exception; + +/** + * 사진 관련 예외 + */ +public class PhotoException extends BaseException { + + public PhotoException(ErrorMessage errorMessage) { + super(errorMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/common/exception/PushException.java b/src/main/java/com/example/chalpu/common/exception/PushException.java new file mode 100644 index 0000000..36c7869 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/PushException.java @@ -0,0 +1,11 @@ +package com.example.chalpu.common.exception; + +/** + * 푸시 알림 설정 관련 예외 + */ +public class PushException extends BaseException { + + public PushException(ErrorMessage errorMessage) { + super(errorMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/common/exception/RefreshTokenException.java b/src/main/java/com/example/chalpu/common/exception/RefreshTokenException.java new file mode 100644 index 0000000..9f50ed9 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/RefreshTokenException.java @@ -0,0 +1,7 @@ +package com.example.chalpu.common.exception; + +public class RefreshTokenException extends BaseException { + public RefreshTokenException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/example/chalpu/common/exception/S3Exception.java b/src/main/java/com/example/chalpu/common/exception/S3Exception.java new file mode 100644 index 0000000..a2fa406 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/S3Exception.java @@ -0,0 +1,7 @@ +package com.example.chalpu.common.exception; + +public class S3Exception extends BaseException { + public S3Exception(ErrorMessage errorMessage) { + super(errorMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/common/exception/StoreException.java b/src/main/java/com/example/chalpu/common/exception/StoreException.java new file mode 100644 index 0000000..8789cd8 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/StoreException.java @@ -0,0 +1,11 @@ +package com.example.chalpu.common.exception; + +/** + * 매장 관련 예외 + */ +public class StoreException extends BaseException { + + public StoreException(ErrorMessage errorMessage) { + super(errorMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/common/exception/UserException.java b/src/main/java/com/example/chalpu/common/exception/UserException.java new file mode 100644 index 0000000..0a8727e --- /dev/null +++ b/src/main/java/com/example/chalpu/common/exception/UserException.java @@ -0,0 +1,11 @@ +package com.example.chalpu.common.exception; + +/** + * 사용자 관련 예외 + */ +public class UserException extends BaseException { + + public UserException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/example/chalpu/common/response/ApiResponse.java b/src/main/java/com/example/chalpu/common/response/ApiResponse.java new file mode 100644 index 0000000..f835d5d --- /dev/null +++ b/src/main/java/com/example/chalpu/common/response/ApiResponse.java @@ -0,0 +1,48 @@ +package com.example.chalpu.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + private Integer code; + private String message; + private T result; + + /** + * 성공 응답 - 데이터와 함께 + */ + public static ApiResponse success(T result) { + return new ApiResponse<>(200, "API 요청이 성공했습니다.", result); + } + + /** + * 성공 응답 - 데이터 없음 + */ + public static ApiResponse success() { + return new ApiResponse<>(200, "API 요청이 성공했습니다.", null); + } + + /** + * 성공 응답 - 커스텀 메시지 + */ + public static ApiResponse success(String message, T result) { + return new ApiResponse<>(200, message, result); + } + /** + * 서버 오류 응답 - 커스텀 메시지 + */ + public static ApiResponse serverError(String message) { + return new ApiResponse<>(500, message, null); + } + + /** + * 인증 오류 응답 + */ + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} diff --git a/src/main/java/com/example/chalpu/common/response/PageResponse.java b/src/main/java/com/example/chalpu/common/response/PageResponse.java new file mode 100644 index 0000000..93bd9f3 --- /dev/null +++ b/src/main/java/com/example/chalpu/common/response/PageResponse.java @@ -0,0 +1,52 @@ +package com.example.chalpu.common.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@Builder +@Schema(description = "페이지네이션 응답") +public class PageResponse { + + @Schema(description = "데이터 목록") + private List content; + + @Schema(description = "현재 페이지 번호 (0부터 시작)", example = "0") + private int page; + + @Schema(description = "페이지 크기", example = "20") + private int size; + + @Schema(description = "전체 요소 수", example = "100") + private long totalElements; + + @Schema(description = "전체 페이지 수", example = "5") + private int totalPages; + + @Schema(description = "다음 페이지 존재 여부", example = "true") + private boolean hasNext; + + @Schema(description = "이전 페이지 존재 여부", example = "false") + private boolean hasPrevious; + + /** + * Spring Data Page 객체를 PageResponse로 변환 + */ + public static PageResponse from(Page page) { + return PageResponse.builder() + .content(page.getContent()) + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .hasNext(page.hasNext()) + .hasPrevious(page.hasPrevious()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/fcm/config/FirebaseConfig.java b/src/main/java/com/example/chalpu/fcm/config/FirebaseConfig.java new file mode 100644 index 0000000..1c94996 --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/config/FirebaseConfig.java @@ -0,0 +1,53 @@ +package com.example.chalpu.fcm.config; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.NotificationException; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; +import java.io.*; + +/** + * Firebase Configuration + * Firebase Admin SDK 초기화 및 설정을 담당하는 클래스 + * + */ +@Slf4j +@Configuration +public class FirebaseConfig { + + @Value("${fcm.service-account-key-json}") + private String serviceAccountKeyJson; + + @PostConstruct + public void initializeFirebase() { + try { + if (FirebaseApp.getApps().isEmpty()) { + InputStream serviceAccount = new ByteArrayInputStream(serviceAccountKeyJson.getBytes()); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + FirebaseApp.initializeApp(options); + log.info("Firebase 초기화 완료"); + } else { + log.info("Firebase는 이미 초기화되어 있습니다."); + } + + } catch (IOException e) { + log.error("Firebase 초기화 실패: Service Account Key JSON 파싱 실패 - {}", e.getMessage()); + throw new NotificationException(ErrorMessage.NOTIFICATION_FIREBASE_INIT_FAILED); + } catch (Exception e) { + log.error("Firebase 초기화 중 예상치 못한 오류 발생: {}", e.getMessage()); + throw new NotificationException(ErrorMessage.NOTIFICATION_FIREBASE_INIT_FAILED); + } + } + +} + diff --git a/src/main/java/com/example/chalpu/fcm/domain/DeviceType.java b/src/main/java/com/example/chalpu/fcm/domain/DeviceType.java new file mode 100644 index 0000000..6aa0205 --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/domain/DeviceType.java @@ -0,0 +1,78 @@ +package com.example.chalpu.fcm.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +/** + * FCM 디바이스 타입 열거형 + * + * @author Backend Team + * @version 1.0 + * @since 2025-06-09 + */ +@Getter +@Schema(description = "FCM 디바이스 타입", example = "ANDROID") +public enum DeviceType { + + @Schema(description = "안드로이드 기기에서 사용해야 해요") + ANDROID("android"), + + @Schema(description = "iOS 기기에서 사용해야 해요") + IOS("ios"), + + @Schema(description = "웹 브라우저에서 사용해야 해요") + WEB("web"); + + private final String value; + + DeviceType(String value) { + this.value = value; + } + + /** + * 문자열로부터 DeviceType을 반환합니다. + * @param value 디바이스 타입 문자열 (대소문자 무관) + * @return 해당하는 DeviceType + * @throws IllegalArgumentException 지원하지 않는 디바이스 타입인 경우 + */ + public static DeviceType from(String value) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("디바이스 타입이 비어있습니다."); + } + + for (DeviceType type : values()) { + if (type.value.equalsIgnoreCase(value.trim())) { + return type; + } + } + + throw new IllegalArgumentException("지원하지 않는 디바이스 타입입니다: " + value); + } + + /** + * 디바이스 타입이 유효한지 확인합니다. + * @param value 디바이스 타입 문자열 + * @return 유효 여부 + */ + public static boolean isValid(String value) { + if (value == null || value.trim().isEmpty()) { + return false; + } + + for (DeviceType type : values()) { + if (type.value.equalsIgnoreCase(value.trim())) { + return true; + } + } + + return false; + } + + /** + * @deprecated from() 메소드를 사용하세요. + */ + @Deprecated + public static DeviceType fromString(String value) { + return from(value); + } +} diff --git a/src/main/java/com/example/chalpu/fcm/domain/UserFCMToken.java b/src/main/java/com/example/chalpu/fcm/domain/UserFCMToken.java new file mode 100644 index 0000000..1ba721a --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/domain/UserFCMToken.java @@ -0,0 +1,107 @@ +package com.example.chalpu.fcm.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 사용자 FCM 토큰 엔티티 + * 사용자의 디바이스별 FCM 토큰을 저장하는 엔티티 + * + * @author Backend Team + * @version 1.0 + * @since 2025-06-09 + */ +@Entity +@Table( + name = "user_devices") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserFCMToken extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 사용자 ID + */ + @Column(name = "user_id", nullable = false) + private Long userId; + + /** + * FCM 토큰 (디바이스별로 고유) + */ + @Column(name = "fcm_token", length = 500, nullable = false) + private String fcmToken; + + /** + * 디바이스 모델명 (예: iPhone 14 Pro, Galaxy S23) + */ + @Column(name = "device_model", length = 100) + private String deviceModel; + + /** + * 디바이스 타입 (android, ios) + */ + @Enumerated(EnumType.STRING) + @Column(name = "device_type", length = 10) + private DeviceType deviceType; + + @Column(name = "app_version") + private String appVersion; + + @Column(name = "os_version") + private String osVersion; + + + /** + * 토큰 활성화 상태 + */ + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + /** + * 마지막 사용 시간 (알림 전송 시 업데이트) + */ + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + /** + * FCM 토큰 업데이트 + * @param newToken 새로운 FCM 토큰 + */ + public void updateToken(String newToken) { + this.fcmToken = newToken; + this.lastUsedAt = LocalDateTime.now(); + } + + /** + * 토큰 비활성화 + * 무효한 토큰인 경우 비활성화 처리 + */ + public void softDelete() { + this.isActive = false; + } + + /** + * 토큰 활성화 + */ + public void activate() { + this.isActive = true; + this.lastUsedAt = LocalDateTime.now(); + } + + /** + * 마지막 사용 시간 업데이트 + * 알림 전송 성공 시 호출 + */ + public void updateLastUsed() { + this.lastUsedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/example/chalpu/fcm/dto/FCMTokenRequest.java b/src/main/java/com/example/chalpu/fcm/dto/FCMTokenRequest.java new file mode 100644 index 0000000..2b54415 --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/dto/FCMTokenRequest.java @@ -0,0 +1,16 @@ +package com.example.chalpu.fcm.dto; + +import lombok.*; + +/** + * FCM 토큰 등록 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FCMTokenRequest { + private Long userId; + private String fcmToken; + private String deviceType; // "android" or "ios" +} diff --git a/src/main/java/com/example/chalpu/fcm/dto/MultipleNotificationRequest.java b/src/main/java/com/example/chalpu/fcm/dto/MultipleNotificationRequest.java new file mode 100644 index 0000000..3e125fd --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/dto/MultipleNotificationRequest.java @@ -0,0 +1,19 @@ +package com.example.chalpu.fcm.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +/** + * 다중 사용자 알림 전송 요청 DTO + */ +@Getter +@Setter +@NoArgsConstructor +public class MultipleNotificationRequest { + + private List userIds; + private NotificationRequest notification; +} diff --git a/src/main/java/com/example/chalpu/fcm/dto/NotificationRequest.java b/src/main/java/com/example/chalpu/fcm/dto/NotificationRequest.java new file mode 100644 index 0000000..37e6137 --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/dto/NotificationRequest.java @@ -0,0 +1,71 @@ +package com.example.chalpu.fcm.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 알림 전송 요청 DTO + * FCM 푸시 알림 전송을 위한 요청 데이터를 담는 클래스 + * + * @author Backend Team + * @version 1.0 + * @since 2025-06-09 + */ +@Schema( + description = "FCM 알림 전송 요청", + example = """ + { + "title": "새로운 알림", + "body": "새로운 캠페인이 등록되었습니다!", + "imageUrl": "https://example.com/campaign-image.jpg", + "data": { + "type": "campaign", + "campaign_id": "12345", + "action": "open_campaign" + }, + "priority": "high" + } + """ +) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class NotificationRequest { + + @Schema(description = "알림 제목", example = "새로운 알림", required = true) + private String title; + + @Schema(description = "알림 내용", example = "새로운 캠페인이 등록되었습니다!", required = true) + private String body; + + @Schema(description = "알림 이미지 URL (HTTPS만 지원)", example = "https://example.com/campaign-image.jpg") + private String imageUrl; + + @Schema( + description = "추가 데이터 (key-value 형태)", + example = """ + { + "type": "campaign", + "campaign_id": "12345", + "action": "open_campaign" + } + """ + ) + @Builder.Default + private Map data = new HashMap<>(); + + @Schema(description = "알림 우선순위 (high 또는 normal)", example = "high") + @Builder.Default + private String priority = "high"; + + public static NotificationRequest createSimple(String title, String body) { + return NotificationRequest.builder() + .title(title) + .body(body) + .build(); + } +} diff --git a/src/main/java/com/example/chalpu/fcm/dto/NotificationResultDto.java b/src/main/java/com/example/chalpu/fcm/dto/NotificationResultDto.java new file mode 100644 index 0000000..3237636 --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/dto/NotificationResultDto.java @@ -0,0 +1,89 @@ +package com.example.chalpu.fcm.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 알림 전송 결과 응답 DTO + * 알림 전송 결과를 클라이언트에게 전달하기 위한 공용 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotificationResultDto { + + /** + * 전송 성공 수 + */ + private int successCount; + + /** + * 전송 실패 수 + */ + private int failureCount; + + /** + * 총 전송 대상 수 + */ + private int totalCount; + + /** + * 전송 결과 상세 메시지 + */ + private String details; + + /** + * 전송 완료 시간 + */ + private LocalDateTime completedAt; + + /** + * 추가 정보 (필요시 사용) + */ + private Map additionalInfo; + + /** + * 단일 사용자 전송 성공 결과 생성 + */ + public static NotificationResultDto singleSuccess() { + return NotificationResultDto.builder() + .successCount(1) + .failureCount(0) + .totalCount(1) + .details("알림 전송 성공") + .completedAt(LocalDateTime.now()) + .build(); + } + + /** + * 다중 사용자 전송 결과 생성 + */ + public static NotificationResultDto multipleResult(int successCount, int failureCount, String details) { + return NotificationResultDto.builder() + .successCount(successCount) + .failureCount(failureCount) + .totalCount(successCount + failureCount) + .details(details) + .completedAt(LocalDateTime.now()) + .build(); + } + + /** + * 토픽 전송 결과 생성 + */ + public static NotificationResultDto topicResult(String topicName) { + return NotificationResultDto.builder() + .successCount(1) + .failureCount(0) + .totalCount(1) + .details(String.format("토픽 '%s' 알림 전송 완료", topicName)) + .completedAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/example/chalpu/fcm/repository/UserFCMTokenRepository.java b/src/main/java/com/example/chalpu/fcm/repository/UserFCMTokenRepository.java new file mode 100644 index 0000000..a76df59 --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/repository/UserFCMTokenRepository.java @@ -0,0 +1,101 @@ +package com.example.chalpu.fcm.repository; + +import com.example.chalpu.fcm.domain.DeviceType; +import com.example.chalpu.fcm.domain.UserFCMToken; +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.List; +import java.util.Optional; + +/** + * FCM 토큰 Repository + * 사용자 FCM 토큰 관련 데이터베이스 접근을 담당하는 인터페이스 + * + * @author Backend Team + * @version 1.0 + * @since 2025-06-09 + */ +@Repository +public interface UserFCMTokenRepository extends JpaRepository { + + /** + * 사용자 ID로 활성화된 모든 토큰 조회 + * @param userId 사용자 ID + * @return 활성화된 FCM 토큰 리스트 + */ + @Query("SELECT t FROM UserFCMToken t WHERE t.userId = :userId AND t.isActive = true") + List findActiveTokensByUserId(@Param("userId") Long userId); + + /** + * 사용자 ID와 디바이스 타입으로 토큰 조회 + * @param userId 사용자 ID + * @param deviceType 디바이스 타입 + * @return FCM 토큰 (Optional) + */ + Optional findByUserIdAndDeviceTypeAndIsActiveTrue(Long userId, DeviceType deviceType); + + /** + * FCM 토큰으로 활성화된 토큰 조회 + * @param fcmToken FCM 토큰 + * @return FCM 토큰 엔티티 (Optional) + */ + Optional findByFcmTokenAndIsActiveTrue(String fcmToken); + + /** + * 여러 사용자 ID로 활성화된 토큰들 조회 + * @param userIds 사용자 ID 리스트 + * @return 활성화된 FCM 토큰 리스트 + */ + @Query("SELECT t FROM UserFCMToken t WHERE t.userId IN :userIds AND t.isActive = true") + List findActiveTokensByUserIds(@Param("userIds") List userIds); + + + /** + * 마지막 사용 시간이 특정 기간 이전인 토큰들 조회 (정리용) + * @param dateTime 기준 시간 + * @return 오래된 FCM 토큰 리스트 + */ + @Query("SELECT t FROM UserFCMToken t WHERE t.lastUsedAt < :dateTime AND t.isActive = true") + List findTokensNotUsedSince(@Param("dateTime") LocalDateTime dateTime); + + /** + * 특정 FCM 토큰 비활성화 + * @param fcmToken FCM 토큰 + */ + @Modifying + @Query("UPDATE UserFCMToken t SET t.isActive = false WHERE t.fcmToken = :fcmToken") + void deactivateByFcmToken(@Param("fcmToken") String fcmToken); + + /** + * 사용자의 모든 토큰 비활성화 + * @param userId 사용자 ID + */ + @Modifying + @Query("UPDATE UserFCMToken t SET t.isActive = false WHERE t.userId = :userId") + void deactivateAllTokensByUserId(@Param("userId") Long userId); + + + @Modifying + @Query("UPDATE UserFCMToken t SET t.isActive = true WHERE t.userId = :userId") + void activateByUserId(@Param("userId") Long userId); + + /** + * 음식 아이템과 연관된 모든 토큰들을 소프트 딜리트 (사용자 기준) + * @param userId 사용자 ID + */ + @Modifying + @Query("UPDATE UserFCMToken t SET t.isActive = false WHERE t.userId = :userId") + void softDeleteByUserId(@Param("userId") Long userId); + + /** + * 사용자 ID로 토큰 존재 여부 확인 + * @param userId 사용자 ID + * @return 토큰 존재 여부 + */ + boolean existsByUserIdAndIsActiveTrue(Long userId); +} diff --git a/src/main/java/com/example/chalpu/fcm/service/FCMTokenService.java b/src/main/java/com/example/chalpu/fcm/service/FCMTokenService.java new file mode 100644 index 0000000..bccc2d0 --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/service/FCMTokenService.java @@ -0,0 +1,104 @@ +package com.example.chalpu.fcm.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.FCMException; +import com.example.chalpu.fcm.domain.DeviceType; +import com.example.chalpu.fcm.domain.UserFCMToken; +import com.example.chalpu.fcm.dto.FCMTokenRequest; +import com.example.chalpu.fcm.repository.UserFCMTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * FCM 토큰 관리 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FCMTokenService { + + private final UserFCMTokenRepository tokenRepository; + + /** + * FCM 토큰 등록 또는 업데이트 + */ + @Transactional + public Long registerOrUpdateToken(FCMTokenRequest request) { + try { + Long userId = request.getUserId(); + String fcmToken = request.getFcmToken(); + DeviceType deviceType = DeviceType.from(request.getDeviceType()); + + if (!isValidTokenRequest(request)) { + throw new FCMException(ErrorMessage.FCM_TOKEN_INVALID_REQUEST); + } + + Optional existingToken = tokenRepository + .findByUserIdAndDeviceTypeAndIsActiveTrue(userId, deviceType); + + if (existingToken.isPresent()) { + UserFCMToken token = existingToken.get(); + token.updateToken(fcmToken); + token.activate(); + + log.info("FCM 토큰 업데이트 - 사용자: {}, 디바이스: {}", userId, deviceType); + return token.getId(); + } else { + UserFCMToken newToken = UserFCMToken.builder() + .userId(userId) + .fcmToken(fcmToken) + .isActive(true) + .deviceType(deviceType) + .build(); + + UserFCMToken savedToken = tokenRepository.save(newToken); + + log.info("새 FCM 토큰 등록 - 사용자: {}, 디바이스: {}", userId, deviceType); + return savedToken.getId(); + } + + } catch (IllegalArgumentException e) { + log.error("잘못된 디바이스 타입: {}", request.getDeviceType()); + throw new FCMException(ErrorMessage.FCM_DEVICE_TYPE_INVALID); + } catch (FCMException e) { + throw e; + } catch (Exception e) { + log.error("FCM 토큰 등록/업데이트 실패: {}", e.getMessage()); + throw new FCMException(ErrorMessage.FCM_TOKEN_REGISTRATION_FAILED); + } + } + + /** + * 오래된 토큰 정리 (스케줄링) + * 90일 이상 사용되지 않은 토큰들을 비활성화 + */ + @Scheduled(cron = "0 0 2 * * ?",zone = "Asia/Seoul") + @Transactional + public void cleanupOldTokens() { + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(90); + List oldTokens = tokenRepository.findTokensNotUsedSince(cutoffDate); + + if (!oldTokens.isEmpty()) { + tokenRepository.deleteAll(oldTokens); // 진짜 삭제 + log.info("오래된 FCM 토큰 삭제 완료 - 삭제된 토큰 수: {}", oldTokens.size()); + } + } + + /** + * 토큰 요청 유효성 검사 + */ + private boolean isValidTokenRequest(FCMTokenRequest request) { + return request != null && + request.getUserId() != null && + request.getFcmToken() != null && !request.getFcmToken().trim().isEmpty() && + request.getDeviceType() != null && !request.getDeviceType().trim().isEmpty(); + } +} diff --git a/src/main/java/com/example/chalpu/fcm/service/NotificationService.java b/src/main/java/com/example/chalpu/fcm/service/NotificationService.java new file mode 100644 index 0000000..1d3da72 --- /dev/null +++ b/src/main/java/com/example/chalpu/fcm/service/NotificationService.java @@ -0,0 +1,314 @@ +package com.example.chalpu.fcm.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.NotificationException; +import com.example.chalpu.fcm.domain.DeviceType; +import com.example.chalpu.fcm.dto.NotificationRequest; +import com.example.chalpu.fcm.dto.NotificationResultDto; +import com.example.chalpu.fcm.domain.UserFCMToken; +import com.example.chalpu.fcm.repository.UserFCMTokenRepository; +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.ArrayList; +import java.util.stream.Collectors; + +/** + * 알림 전송 서비스 + * FCM을 통한 푸시 알림 전송을 담당하는 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class NotificationService { + + private final UserFCMTokenRepository tokenRepository; + + /** + * 단일 사용자에게 알림 전송 + */ + public NotificationResultDto sendNotificationToUser(Long userId, NotificationRequest request) { + if (!isFirebaseInitialized()) { + log.error("Firebase가 초기화되지 않았습니다. FCM 기능을 사용할 수 없습니다."); + throw new NotificationException(ErrorMessage.NOTIFICATION_SERVICE_UNAVAILABLE); + } + + try { + List tokens = tokenRepository.findActiveTokensByUserId(userId); + + if (tokens.isEmpty()) { + log.error("사용자 {}의 활성화된 FCM 토큰이 없습니다.", userId); + throw new NotificationException(ErrorMessage.NOTIFICATION_USER_NO_TOKENS); + } + + List tokenStrings = tokens.stream() + .map(UserFCMToken::getFcmToken) + .collect(Collectors.toList()); + + return sendMulticastNotification(tokenStrings, request, tokens); + + } catch (NotificationException e) { + throw e; + } catch (Exception e) { + log.error("사용자 {}에게 알림 전송 실패: {}", userId, e.getMessage()); + throw new NotificationException(ErrorMessage.NOTIFICATION_SEND_FAILED); + } + } + + /** + * 여러 사용자에게 알림 전송 + */ + public NotificationResultDto sendNotificationToUsers(List userIds, NotificationRequest request) { + try { + List tokens = tokenRepository.findActiveTokensByUserIds(userIds); + + if (tokens.isEmpty()) { + log.error("지정된 사용자들의 활성화된 FCM 토큰이 없습니다."); + throw new NotificationException(ErrorMessage.NOTIFICATION_USER_NO_TOKENS); + } + + List tokenStrings = tokens.stream() + .map(UserFCMToken::getFcmToken) + .collect(Collectors.toList()); + + return sendMulticastNotification(tokenStrings, request, tokens); + + } catch (NotificationException e) { + throw e; + } catch (Exception e) { + log.error("여러 사용자에게 알림 전송 실패: {}", e.getMessage()); + throw new NotificationException(ErrorMessage.NOTIFICATION_MULTIPLE_SEND_FAILED); + } + } + + /** + * 토픽을 통한 알림 전송 + * 이 부분은 프론트와 협의해서 토픽을 서버에 저장할 지 고민 + */ + public NotificationResultDto sendNotificationToTopic(String topic, NotificationRequest request) { + try { + Message message = Message.builder() + .setNotification(buildNotification(request)) + .putAllData(request.getData()) + .setTopic(topic) + .setAndroidConfig(buildAndroidConfig(request)) + .setApnsConfig(buildApnsConfig(request)) + .build(); + + String response = FirebaseMessaging.getInstance().send(message); + log.info("토픽 {} 알림 전송 성공: {}", topic, response); + + return NotificationResultDto.topicResult(topic); + + } catch (FirebaseMessagingException e) { + log.error("토픽 {} 알림 전송 실패: {}", topic, e.getMessage()); + throw new NotificationException(ErrorMessage.NOTIFICATION_TOPIC_SEND_FAILED); + } + } + + /** + * 특정 FCM 토큰으로 직접 알림 전송 + */ + public NotificationResultDto sendNotificationToToken(String fcmToken, NotificationRequest request) { + try { + Message message = Message.builder() + .setNotification(buildNotification(request)) + .putAllData(request.getData()) + .setToken(fcmToken) + .setAndroidConfig(buildAndroidConfig(request)) + .setApnsConfig(buildApnsConfig(request)) + .build(); + + String response = FirebaseMessaging.getInstance().send(message); + log.info("FCM 토큰 알림 전송 성공: {}", response); + + updateTokenLastUsed(fcmToken); + + return NotificationResultDto.singleSuccess(); + + } catch (FirebaseMessagingException e) { + log.error("FCM 토큰 알림 전송 실패: {}", e.getMessage()); + + if (isInvalidTokenError(e)) { + deactivateToken(fcmToken); + } + + throw new NotificationException(ErrorMessage.NOTIFICATION_TOKEN_SEND_FAILED); + } + } + + /** + * 다중 토큰으로 알림 전송 (내부 메서드) + */ + private NotificationResultDto sendMulticastNotification(List tokens, NotificationRequest request, List tokenEntities) { + try { + List> tokenChunks = chunkList(tokens, 500); + int totalSuccess = 0; + int totalFailure = 0; + List invalidTokens = new ArrayList<>(); + + for (List chunk : tokenChunks) { + MulticastMessage message = MulticastMessage.builder() + .setNotification(buildNotification(request)) + .putAllData(request.getData()) + .addAllTokens(chunk) + .setAndroidConfig(buildAndroidConfig(request)) + .setApnsConfig(buildApnsConfig(request)) + .build(); + + BatchResponse batchResponse = FirebaseMessaging.getInstance().sendEachForMulticast(message); + totalSuccess += batchResponse.getSuccessCount(); + totalFailure += batchResponse.getFailureCount(); + + processFailedTokens(batchResponse, chunk, invalidTokens); + } + + updateSuccessfulTokensLastUsed(tokenEntities, invalidTokens); + deactivateInvalidTokens(invalidTokens); + + log.info("알림 전송 완료 - 성공: {}, 실패: {}, 무효 토큰: {}", + totalSuccess, totalFailure, invalidTokens.size()); + + return NotificationResultDto.multipleResult(totalSuccess, totalFailure, + String.format("성공: %d, 실패: %d", totalSuccess, totalFailure)); + + } catch (FirebaseMessagingException e) { + log.error("다중 알림 전송 실패: {}", e.getMessage()); + throw new NotificationException(ErrorMessage.NOTIFICATION_MULTIPLE_SEND_FAILED); + } + } + + /** + * 알림 객체 생성 + */ + private Notification buildNotification(NotificationRequest request) { + return Notification.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) + .setImage(request.getImageUrl()) + .build(); + } + + /** + * Android 설정 생성 + */ + private AndroidConfig buildAndroidConfig(NotificationRequest request) { + return AndroidConfig.builder() + .setNotification(AndroidNotification.builder() + .build()) + .setPriority(getPriority(request.getPriority())) + .build(); + } + + /** + * iOS APNS 설정 생성 + */ + private ApnsConfig buildApnsConfig(NotificationRequest request) { + return ApnsConfig.builder() + .setAps(Aps.builder() + .setAlert(ApsAlert.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) + .build()) + .build()) + .build(); + } + + /** + * 우선순위 변환 + */ + private AndroidConfig.Priority getPriority(String priority) { + if ("high".equalsIgnoreCase(priority)) { + return AndroidConfig.Priority.HIGH; + } + return AndroidConfig.Priority.NORMAL; + } + + /** + * 실패한 토큰들 처리 + */ + private void processFailedTokens(BatchResponse batchResponse, List tokens, List invalidTokens) { + List responses = batchResponse.getResponses(); + for (int i = 0; i < responses.size(); i++) { + SendResponse response = responses.get(i); + if (!response.isSuccessful()) { + String token = tokens.get(i); + FirebaseMessagingException exception = response.getException(); + + if (isInvalidTokenError(exception)) { + invalidTokens.add(token); + log.error("무효한 토큰 발견: {}", token); + } else { + log.error("토큰 {} 전송 실패: {}", token, exception.getMessage()); + } + } + } + } + + /** + * 무효한 토큰 에러인지 확인 + */ + private boolean isInvalidTokenError(FirebaseMessagingException e) { + return e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT || + e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED; + } + + /** + * 성공한 토큰들의 마지막 사용 시간 업데이트 + */ + private void updateSuccessfulTokensLastUsed(List tokenEntities, List invalidTokens) { + tokenEntities.stream() + .filter(token -> !invalidTokens.contains(token.getFcmToken())) + .forEach(UserFCMToken::updateLastUsed); + } + + /** + * 무효한 토큰들 비활성화 + */ + public void deactivateInvalidTokens(List invalidTokens) { + for (String token : invalidTokens) { + deactivateToken(token); + } + } + + /** + * 특정 토큰 비활성화 + */ + public void deactivateToken(String fcmToken) { + tokenRepository.deactivateByFcmToken(fcmToken); + log.info("FCM 토큰 비활성화: {}", fcmToken.substring(0, Math.min(20, fcmToken.length())) + "..."); + } + + /** + * 토큰 마지막 사용 시간 업데이트 + */ + public void updateTokenLastUsed(String fcmToken) { + tokenRepository.findByFcmTokenAndIsActiveTrue(fcmToken) + .ifPresent(UserFCMToken::updateLastUsed); + } + + /** + * 리스트를 지정된 크기로 분할 + */ + private List> chunkList(List list, int chunkSize) { + List> chunks = new ArrayList<>(); + for (int i = 0; i < list.size(); i += chunkSize) { + chunks.add(list.subList(i, Math.min(i + chunkSize, list.size()))); + } + return chunks; + } + + /** + * Firebase 초기화 상태 확인 + */ + private boolean isFirebaseInitialized() { + return !FirebaseApp.getApps().isEmpty(); + } +} diff --git a/src/main/java/com/example/chalpu/fooditem/controller/FoodItemController.java b/src/main/java/com/example/chalpu/fooditem/controller/FoodItemController.java new file mode 100644 index 0000000..ad5463d --- /dev/null +++ b/src/main/java/com/example/chalpu/fooditem/controller/FoodItemController.java @@ -0,0 +1,105 @@ +package com.example.chalpu.fooditem.controller; + +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.fooditem.dto.FoodItemRequest; +import com.example.chalpu.fooditem.dto.FoodItemResponse; +import com.example.chalpu.fooditem.service.FoodItemService; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/foods") +@RequiredArgsConstructor +@Tag(name = "FoodItem", description = "음식 관련 API") +public class FoodItemController { + + private final FoodItemService foodItemService; + + @GetMapping("/store/{storeId}") + @Operation( + summary = "매장별 음식 목록 조회", + description = "특정 매장의 음식 목록을 페이지네이션하여 조회합니다.", + security = { @SecurityRequirement(name = "bearerAuth") } + ) + public ApiResponse> getFoodItems( + @PathVariable @Parameter(description = "매장 ID") Long storeId, + @PageableDefault(size = 20) Pageable pageable) { + PageResponse foodItems = foodItemService.getFoodItems(storeId, pageable); + return ApiResponse.success(foodItems); + } + + @GetMapping("/{foodId}") + @Operation( + summary = "음식 상세 조회", + description = "특정 음식의 상세 정보를 조회합니다." + ) + public ApiResponse getFoodItem( + @PathVariable @Parameter(description = "음식 ID") Long foodId) { + FoodItemResponse foodItem = foodItemService.getFoodItem(foodId); + return ApiResponse.success(foodItem); + } + + @PostMapping("/store/{storeId}") + @Operation( + summary = "음식 생성", + description = "새로운 음식을 생성합니다.", + security = { @SecurityRequirement(name = "bearerAuth") } + ) + public ApiResponse createFoodItem( + @PathVariable @Parameter(description = "매장 ID") Long storeId, + @RequestBody FoodItemRequest request, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + FoodItemResponse foodItem = foodItemService.createFoodItem(storeId, request, userDetails.getId()); + return ApiResponse.success(foodItem); + } + + @PutMapping("/{foodId}") + @Operation( + summary = "음식 수정", + description = "기존 음식 정보를 수정합니다.", + security = { @SecurityRequirement(name = "bearerAuth") } + ) + public ApiResponse updateFoodItem( + @PathVariable @Parameter(description = "음식 ID") Long foodId, + @RequestBody FoodItemRequest request, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + FoodItemResponse foodItem = foodItemService.updateFoodItem(foodId, request, userDetails.getId()); + return ApiResponse.success(foodItem); + } + + @DeleteMapping("/{foodId}") + @Operation( + summary = "음식 삭제", + description = "음식을 삭제합니다 (소프트 딜리트).", + security = { @SecurityRequirement(name = "bearerAuth") } + ) + public ApiResponse deleteFoodItem( + @PathVariable @Parameter(description = "음식 ID") Long foodId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + foodItemService.deleteFoodItem(foodId, userDetails.getId()); + return ApiResponse.success(); + } + + @GetMapping("/store/{storeId}/search") + @Operation( + summary = "음식 검색", + description = "매장 내에서 음식명으로 검색합니다." + ) + public ApiResponse> searchFoodItems( + @PathVariable @Parameter(description = "매장 ID") Long storeId, + @RequestParam @Parameter(description = "검색 키워드") String keyword, + @PageableDefault(size = 20) Pageable pageable) { + PageResponse foodItems = foodItemService.searchFoodItems(storeId, keyword, pageable); + return ApiResponse.success(foodItems); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/fooditem/domain/FoodItem.java b/src/main/java/com/example/chalpu/fooditem/domain/FoodItem.java new file mode 100644 index 0000000..4a251b0 --- /dev/null +++ b/src/main/java/com/example/chalpu/fooditem/domain/FoodItem.java @@ -0,0 +1,71 @@ +package com.example.chalpu.fooditem.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import com.example.chalpu.fooditem.dto.FoodItemRequest; +import com.example.chalpu.store.domain.Store; +import jakarta.persistence.*; +import lombok.*; +import java.math.BigDecimal; + +@NamedEntityGraph(name = "FoodItem.withStore", attributeNodes = @NamedAttributeNode("store")) +@Entity +@Table(name = "food_items") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = false) +public class FoodItem extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "food_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id") + private Store store; + + @Column(length = 100, nullable = false) + private String foodName; + + private String description; + private String ingredients; + private String cookingMethod; + private BigDecimal price; + private String thumbnailUrl; + + @Builder.Default + private Boolean isActive = true; + + // 정적 팩토리 메서드 + public static FoodItem createFoodItem(Store store, FoodItemRequest request) { + return FoodItem.builder() + .store(store) + .foodName(request.getFoodName()) + .description(request.getDescription()) + .ingredients(request.getIngredients()) + .cookingMethod(request.getCookingMethod()) + .price(request.getPrice()) + .isActive(request.getIsActive() != null ? request.getIsActive() : true) + .build(); + } + + // 업데이트 메서드 + public void updateFoodItem(FoodItemRequest request) { + this.foodName = request.getFoodName(); + this.description = request.getDescription(); + this.ingredients = request.getIngredients(); + this.cookingMethod = request.getCookingMethod(); + this.thumbnailUrl = request.getThumbnailUrl(); + this.price = request.getPrice(); + if (request.getIsActive() != null) { + this.isActive = request.getIsActive(); + } + } + + // 소프트 딜리트 + public void softDelete() { + this.isActive = false; + // 연관된 엔티티들은 Repository를 통해 서비스 레이어에서 처리 + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/fooditem/dto/FoodItemRequest.java b/src/main/java/com/example/chalpu/fooditem/dto/FoodItemRequest.java new file mode 100644 index 0000000..1497d05 --- /dev/null +++ b/src/main/java/com/example/chalpu/fooditem/dto/FoodItemRequest.java @@ -0,0 +1,42 @@ +package com.example.chalpu.fooditem.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "음식 생성/수정 요청") +public class FoodItemRequest { + + @Schema(description = "음식명", example = "김치찌개", required = true) + private String foodName; + + @Schema(description = "설명", example = "매콤하고 시원한 김치찌개") + private String description; + + @Schema(description = "재료", example = "김치, 돼지고기, 두부, 대파") + private String ingredients; + + @Schema(description = "조리법", example = "김치를 볶아 우린 후 끓인다") + private String cookingMethod; + + @Schema(description = "썸네일 url", example = "cdn.chalpu.com/food-thumbnail.jpg") + private String thumbnailUrl; + + @Schema(description = "가격", example = "8000") + private BigDecimal price; + + @Schema(description = "음식 재고") + private Integer stock; + + @Schema(description = "음식 활성화 여부") + @Builder.Default + private Boolean isActive = true; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/fooditem/dto/FoodItemResponse.java b/src/main/java/com/example/chalpu/fooditem/dto/FoodItemResponse.java new file mode 100644 index 0000000..d99da66 --- /dev/null +++ b/src/main/java/com/example/chalpu/fooditem/dto/FoodItemResponse.java @@ -0,0 +1,68 @@ +package com.example.chalpu.fooditem.dto; + +import com.example.chalpu.fooditem.domain.FoodItem; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "음식 응답") +public class FoodItemResponse { + + @Schema(description = "음식 ID", example = "1") + private Long foodItemId; + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "음식명", example = "김치찌개") + private String foodName; + + @Schema(description = "설명", example = "매콤하고 시원한 김치찌개") + private String description; + + @Schema(description = "재료", example = "김치, 돼지고기, 두부, 대파") + private String ingredients; + + @Schema(description = "조리법", example = "김치를 볶아 우린 후 끓인다") + private String cookingMethod; + + @Schema(description = "가격", example = "8000") + private BigDecimal price; + + @Schema(description = "활성화 여부", example = "true") + private Boolean isActive; + + @Schema(description = "썸네일 URL", example = "https://chalpu.s3.ap-northeast-2.amazonaws.com/photos/stores/1/a1b2c3d4-e5f6-7890-1234-567890abcdef.jpg") + private String thumbnailUrl; + + @Schema(description = "생성 시간", example = "2024-01-15T09:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정 시간", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; + + public static FoodItemResponse from(FoodItem foodItem) { + return FoodItemResponse.builder() + .foodItemId(foodItem.getId()) + .storeId(foodItem.getStore() != null ? foodItem.getStore().getId() : null) + .foodName(foodItem.getFoodName()) + .description(foodItem.getDescription()) + .ingredients(foodItem.getIngredients()) + .cookingMethod(foodItem.getCookingMethod()) + .thumbnailUrl(foodItem.getThumbnailUrl()) + .price(foodItem.getPrice()) + .isActive(foodItem.getIsActive()) + .createdAt(foodItem.getCreatedAt()) + .updatedAt(foodItem.getUpdatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/fooditem/repository/FoodItemRepository.java b/src/main/java/com/example/chalpu/fooditem/repository/FoodItemRepository.java new file mode 100644 index 0000000..ba8711d --- /dev/null +++ b/src/main/java/com/example/chalpu/fooditem/repository/FoodItemRepository.java @@ -0,0 +1,35 @@ +package com.example.chalpu.fooditem.repository; + +import com.example.chalpu.fooditem.domain.FoodItem; +import org.springframework.data.domain.Page; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +@Repository +public interface FoodItemRepository extends JpaRepository { + + // Fetch Join 없이 조회하기 위한 새로운 메서드 + @Query("SELECT fi FROM FoodItem fi WHERE fi.store.id = :storeId AND fi.isActive = true") + Page findByStoreIdAndIsActiveTrueWithoutJoin(@Param("storeId") Long storeId, Pageable pageable); + + // Fetch Join 없이 검색하기 위한 새로운 메서드 + @Query("SELECT fi FROM FoodItem fi WHERE fi.store.id = :storeId AND fi.isActive = true AND fi.foodName LIKE %:foodName%") + Page findByStoreIdAndIsActiveTrueAndFoodNameContainingWithoutJoin(@Param("storeId") Long storeId, @Param("foodName") String foodName, Pageable pageable); + + @EntityGraph(value = "FoodItem.withStore") + Optional findByIdAndIsActiveTrue(Long id); + + // 경량화된 조회 메서드 (연관 엔티티 조회 없음) + @Query("SELECT fi FROM FoodItem fi WHERE fi.id = :id AND fi.isActive = true") + Optional findByIdAndIsActiveTrueWithoutJoin(@Param("id") Long id); + + // 권한 검증용 - storeId만 조회 + @Query("SELECT fi.store.id FROM FoodItem fi WHERE fi.id = :id AND fi.isActive = true") + Optional findStoreIdByFoodItemId(@Param("id") Long id); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/fooditem/service/FoodItemService.java b/src/main/java/com/example/chalpu/fooditem/service/FoodItemService.java new file mode 100644 index 0000000..296ec85 --- /dev/null +++ b/src/main/java/com/example/chalpu/fooditem/service/FoodItemService.java @@ -0,0 +1,189 @@ +package com.example.chalpu.fooditem.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.FoodException; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.fooditem.domain.FoodItem; +import com.example.chalpu.fooditem.dto.FoodItemRequest; +import com.example.chalpu.fooditem.dto.FoodItemResponse; +import com.example.chalpu.fooditem.repository.FoodItemRepository; +import com.example.chalpu.menu.domain.MenuItem; +import com.example.chalpu.menu.repository.MenuItemRepository; +import com.example.chalpu.photo.repository.PhotoRepository; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.store.repository.StoreRepository; +import com.example.chalpu.store.service.UserStoreRoleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FoodItemService { + + private final FoodItemRepository foodItemRepository; + private final StoreRepository storeRepository; + private final UserStoreRoleService userStoreRoleService; + private final MenuItemRepository menuItemRepository; + private final PhotoRepository photoRepository; + + /** + * 매장별 음식 아이템 목록 조회 (활성 음식만) + */ + public PageResponse getFoodItems(Long storeId, Pageable pageable) { + try { + Page foodItemPage = foodItemRepository.findByStoreIdAndIsActiveTrueWithoutJoin(storeId, pageable); + Page foodResponsePage = foodItemPage.map(FoodItemResponse::from); + return PageResponse.from(foodResponsePage); + } catch (Exception e) { + log.error("Failed to retrieve food items: storeId={}", storeId, e); + throw new FoodException(ErrorMessage.FOOD_NOT_FOUND); + } + } + + /** + * 음식 아이템 단건 조회 + */ + public FoodItemResponse getFoodItem(Long foodId) { + try { + FoodItem foodItem = findFoodItemById(foodId); + return FoodItemResponse.from(foodItem); + } catch (Exception e) { + log.error("Food item not found: id={}", foodId, e); + throw new FoodException(ErrorMessage.FOOD_NOT_FOUND); + } + } + + /** + * 음식 아이템 생성 + */ + @Transactional + public FoodItemResponse createFoodItem(Long storeId, FoodItemRequest foodItemRequest, Long userId) { + try { + validateUserStoreAccess(userId, storeId); + Store store = findStoreById(storeId); + + FoodItem foodItem = FoodItem.createFoodItem(store, foodItemRequest); + FoodItem savedFoodItem = foodItemRepository.save(foodItem); + + log.info("event=food_item_created, food_item_id={}, store_id={}, user_id={}", + savedFoodItem.getId(), storeId, userId); + + return FoodItemResponse.from(savedFoodItem); + } catch (Exception e) { + log.error("event=food_item_creation_failed, store_id={}, user_id={}, error_message={}", + storeId, userId, e.getMessage(), e); + throw new FoodException(ErrorMessage.FOOD_CREATE_FAILED); + } + } + + /** + * 음식 아이템 수정 + */ + @Transactional + public FoodItemResponse updateFoodItem(Long foodItemId, FoodItemRequest request, Long userId) { + // 권한 검증 - storeId만 조회하여 성능 최적화 + Long storeId = foodItemRepository.findStoreIdByFoodItemId(foodItemId) + .orElseThrow(() -> new FoodException(ErrorMessage.FOOD_NOT_FOUND)); + + if (!userStoreRoleService.canUserAccessStore(userId, storeId)) { + throw new FoodException(ErrorMessage.STORE_ACCESS_DENIED); + } + + // 실제 업데이트용 - Store 정보 없이 경량화된 조회 + FoodItem foodItem = foodItemRepository.findByIdAndIsActiveTrueWithoutJoin(foodItemId) + .orElseThrow(() -> new FoodException(ErrorMessage.FOOD_NOT_FOUND)); + + foodItem.updateFoodItem(request); + FoodItem savedFoodItem = foodItemRepository.save(foodItem); + + log.info("event=food_item_updated, food_item_id={}, store_id={}", foodItemId, storeId); + return FoodItemResponse.from(savedFoodItem); + } + + /** + * 음식 아이템 삭제 (소프트 딜리트) + */ + @Transactional + public void deleteFoodItem(Long foodItemId, Long userId) { + // 권한 검증 - storeId만 조회하여 성능 최적화 + Long storeId = foodItemRepository.findStoreIdByFoodItemId(foodItemId) + .orElseThrow(() -> new FoodException(ErrorMessage.FOOD_NOT_FOUND)); + + if (!userStoreRoleService.canUserAccessStore(userId, storeId)) { + throw new FoodException(ErrorMessage.STORE_ACCESS_DENIED); + } + + // 1. 연관된 Photo들 소프트 딜리트 + photoRepository.softDeleteByFoodItemId(foodItemId); + + // 2. 연관된 MenuItem들 소프트 딜리트 + List menuItems = menuItemRepository.findByFoodItemIdAndIsActiveTrue(foodItemId); + menuItems.forEach(MenuItem::softDelete); + + // 3. FoodItem 자체 소프트 딜리트 + FoodItem foodItem = foodItemRepository.findByIdAndIsActiveTrueWithoutJoin(foodItemId) + .orElseThrow(() -> new FoodException(ErrorMessage.FOOD_NOT_FOUND)); + foodItem.softDelete(); + + foodItemRepository.save(foodItem); + + log.info("event=food_item_deleted, food_item_id={}, store_id={}", foodItemId, storeId); + } + + /** + * 음식 아이템 검색 (활성 음식만) + */ + public PageResponse searchFoodItems(Long storeId, String keyword, Pageable pageable) { + try { + Page foodItemPage = foodItemRepository.findByStoreIdAndIsActiveTrueAndFoodNameContainingWithoutJoin( + storeId, keyword, pageable); + Page foodResponsePage = foodItemPage.map(FoodItemResponse::from); + return PageResponse.from(foodResponsePage); + } catch (Exception e) { + log.error("Food search error: storeId={}, keyword={}", storeId, keyword, e); + throw new FoodException(ErrorMessage.FOOD_NOT_FOUND); + } + } + + + /** + * 음식 아이템 ID로 조회 (활성 음식만) + */ + private FoodItem findFoodItemById(Long foodId) { + return foodItemRepository.findByIdAndIsActiveTrueWithoutJoin(foodId) + .orElseThrow(() -> new FoodException(ErrorMessage.FOOD_NOT_FOUND)); + } + + /** + * 음식 아이템 ID로 조회 (연관 엔티티 없이, 권한 검증용) + */ + private FoodItem findFoodItemByIdForValidation(Long foodId) { + return foodItemRepository.findByIdAndIsActiveTrueWithoutJoin(foodId) + .orElseThrow(() -> new FoodException(ErrorMessage.FOOD_NOT_FOUND)); + } + + /** + * 매장 ID로 조회 + */ + private Store findStoreById(Long storeId) { + return storeRepository.findById(storeId) + .orElseThrow(() -> new FoodException(ErrorMessage.STORE_NOT_FOUND)); + } + + /** + * 사용자 매장 접근 권한 검증 + */ + private void validateUserStoreAccess(Long userId, Long storeId) { + if (!userStoreRoleService.canUserAccessStore(userId, storeId)) { + throw new FoodException(ErrorMessage.STORE_ACCESS_DENIED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/controller/GuideController.java b/src/main/java/com/example/chalpu/guide/controller/GuideController.java new file mode 100644 index 0000000..7d8f9bf --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/controller/GuideController.java @@ -0,0 +1,68 @@ +package com.example.chalpu.guide.controller; + +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.guide.dto.GuidePresignedUrlRequest; +import com.example.chalpu.guide.dto.GuidePresignedUrlsResponse; +import com.example.chalpu.guide.dto.GuideRegisterRequest; +import com.example.chalpu.guide.dto.GuideResponse; +import com.example.chalpu.guide.dto.GuideDeleteRequest; +import com.example.chalpu.guide.dto.GuideUpdateRequest; +import com.example.chalpu.guide.service.GuideService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "가이드 API", description = "가이드 관련 API") +@RestController +@RequestMapping("api/guides") +@RequiredArgsConstructor +public class GuideController { + + private final GuideService guideService; + + @Operation(summary = "가이드 업로드용 Presigned URL 생성", description = "가이드 파일과 이미지 파일을 S3에 업로드하기 위한 Presigned URL을 각각 생성합니다.") + @PostMapping("/presigned-urls") + public ResponseEntity> getPresignedUrls(@RequestBody GuidePresignedUrlRequest request) { + GuidePresignedUrlsResponse response = guideService.getPresignedUrls(request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "가이드 정보 등록", description = "S3에 파일 업로드 완료 후, 가이드 메타데이터를 서버에 최종 등록합니다.") + @PostMapping + public ResponseEntity> registerGuide(@RequestBody GuideRegisterRequest request) { + GuideResponse response = guideService.registerGuide(request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "가이드 단건 조회", description = "특정 가이드의 상세 정보를 조회합니다.") + @GetMapping("/{guideId}") + public ResponseEntity> getGuide(@PathVariable Long guideId) { + GuideResponse response = guideService.findById(guideId); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "가이드 정보 수정", description = "특정 가이드의 내용, 파일 이름, 카테고리를 수정합니다.") + @PatchMapping("/{guideId}") + public ResponseEntity> updateGuide(@PathVariable Long guideId, @RequestBody GuideUpdateRequest request) { + GuideResponse response = guideService.updateGuide(guideId, request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "가이드 목록 조회", description = "모든 가이드 목록을 페이지네이션하여 조회합니다.") + @GetMapping + public ResponseEntity>> getGuides(Pageable pageable) { + PageResponse response = guideService.findAll(pageable); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "가이드 다중 삭제", description = "요청받은 ID 목록에 해당하는 가이드를 DB와 S3에서 모두 삭제합니다.") + @DeleteMapping + public ResponseEntity> deleteGuides(@RequestBody GuideDeleteRequest request) { + guideService.deleteGuides(request.getGuideIds()); + return ResponseEntity.ok(ApiResponse.success()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/domain/Category.java b/src/main/java/com/example/chalpu/guide/domain/Category.java new file mode 100644 index 0000000..e18cd17 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/domain/Category.java @@ -0,0 +1,42 @@ +package com.example.chalpu.guide.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Table(name = "categories") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Category extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, length = 50) + private String englishName; + + @OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List subCategories = new ArrayList<>(); + + @Builder + public Category(String name, String englishName) { + this.name = name; + this.englishName = englishName; + } + + public void addSubCategory(SubCategory subCategory) { + this.subCategories.add(subCategory); + subCategory.setCategory(this); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/domain/Guide.java b/src/main/java/com/example/chalpu/guide/domain/Guide.java new file mode 100644 index 0000000..818e5d8 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/domain/Guide.java @@ -0,0 +1,90 @@ +package com.example.chalpu.guide.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import com.example.chalpu.tag.domain.Tag; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +@NamedEntityGraph( + name = "Guide.withSubCategoryAndCategory", + attributeNodes = { + @NamedAttributeNode(value = "subCategory", subgraph = "subCategory-with-category") + }, + subgraphs = { + @NamedSubgraph( + name = "subCategory-with-category", + attributeNodes = { + @NamedAttributeNode("category") + } + ) + } +) +@Builder +@Entity +@Table(name = "guides") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Guide extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "guide_id") + private Long id; + + @Column(length = 500, nullable = false, unique = true) + private String guideS3Key; + + @Column(length = 500, nullable = false, unique = true) + private String imageS3Key; + + @Column(length = 500, nullable = false, unique = true) + private String svgS3Key; + + @Column(length = 100, nullable = false) + private String fileName; + + @Column(columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sub_category_id", nullable = false) + private SubCategory subCategory; + + @Column(nullable = false) + @Builder.Default + private Boolean isActive = true; + + @Builder + public Guide(String content, String guideS3Key, String imageS3Key, String fileName, + SubCategory subCategory) { + this.content = content; + this.guideS3Key = guideS3Key; + this.imageS3Key = imageS3Key; + this.fileName = fileName; + this.subCategory = subCategory; + } + + public void update(String content, String fileName, SubCategory subCategory) { + if (content != null) { + this.content = content; + } + if (fileName != null) { + this.fileName = fileName; + } + if (subCategory != null) { + this.subCategory = subCategory; + } + } + + public void softDelete() { + this.isActive = false; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/domain/SubCategory.java b/src/main/java/com/example/chalpu/guide/domain/SubCategory.java new file mode 100644 index 0000000..18b9eb6 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/domain/SubCategory.java @@ -0,0 +1,33 @@ +package com.example.chalpu.guide.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@NamedEntityGraph( + name = "SubCategory.withCategory", + attributeNodes = @NamedAttributeNode("category") +) +@Entity +@Getter +@Setter +@Table(name = "sub_categories") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SubCategory extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false) + private Category category; + + @Builder + public SubCategory(String name) { + this.name = name; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/dto/GuideDeleteRequest.java b/src/main/java/com/example/chalpu/guide/dto/GuideDeleteRequest.java new file mode 100644 index 0000000..8b179f8 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/dto/GuideDeleteRequest.java @@ -0,0 +1,9 @@ +package com.example.chalpu.guide.dto; + +import lombok.Getter; +import java.util.List; + +@Getter +public class GuideDeleteRequest { + private List guideIds; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/dto/GuidePresignedUrlRequest.java b/src/main/java/com/example/chalpu/guide/dto/GuidePresignedUrlRequest.java new file mode 100644 index 0000000..80991bc --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/dto/GuidePresignedUrlRequest.java @@ -0,0 +1,8 @@ +package com.example.chalpu.guide.dto; + +import lombok.Getter; + +@Getter +public class GuidePresignedUrlRequest { + private String fileName; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/dto/GuidePresignedUrlsResponse.java b/src/main/java/com/example/chalpu/guide/dto/GuidePresignedUrlsResponse.java new file mode 100644 index 0000000..c4b2c49 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/dto/GuidePresignedUrlsResponse.java @@ -0,0 +1,15 @@ +package com.example.chalpu.guide.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GuidePresignedUrlsResponse { + private final String guideS3Key; + private final String guideUploadUrl; + private final String imageS3Key; + private final String imageUploadUrl; + private final String svgS3Key; + private final String svgUploadUrl; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/dto/GuideRegisterRequest.java b/src/main/java/com/example/chalpu/guide/dto/GuideRegisterRequest.java new file mode 100644 index 0000000..586c233 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/dto/GuideRegisterRequest.java @@ -0,0 +1,27 @@ +package com.example.chalpu.guide.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class GuideRegisterRequest { + @Schema(description = "S3에 업로드된 파일의 고유 키 (Presigned URL 생성 시 받은 값)", example = "guides/c1f8f3a3-3b1b-4b8b-8b5b-4b8b8b5b4b8b.xml") + private String guideS3Key; + + @Schema(description = "업로드한 파일의 원본 이름", example = "guide_v1.xml") + private String fileName; + + @Schema(description = "S3에 업로드된 이미지의 고유 키 (Presigned URL 생성 시 받은 값)", example = "images/c1f8f3a3-3b1b-4b8b-8b5b-4b8b8b5b4b8b.xml") + private String imageS3Key; + + @Schema(description = "svg 파일의 고유 키 (Presigned URL 생성 시 받은 값)", example = "svgs/c1f8f3a3-3b1b-4b8b-8b5b-4b8b8b5b4b8b.svg") + private String svgS3Key; + + private String content; + private Long subCategoryId; + private List tags; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/dto/GuideResponse.java b/src/main/java/com/example/chalpu/guide/dto/GuideResponse.java new file mode 100644 index 0000000..7910427 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/dto/GuideResponse.java @@ -0,0 +1,42 @@ +package com.example.chalpu.guide.dto; + +import com.example.chalpu.guide.domain.Guide; +import com.example.chalpu.tag.domain.GuideTag; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +public class GuideResponse { + + private final Long guideId; + private final String content; + private final String guideS3Key; + private final String fileName; + private final String imageS3Key; + private final String svgS3Key; + private final String categoryName; + private final String subCategoryName; + private final String updatedAt; + private final List tags; + + public static GuideResponse from(Guide guide, List guideTags) { + return GuideResponse.builder() + .guideId(guide.getId()) + .content(guide.getContent()) + .guideS3Key(guide.getGuideS3Key()) + .fileName(guide.getFileName()) + .imageS3Key(guide.getImageS3Key()) + .svgS3Key(guide.getSvgS3Key()) + .categoryName(guide.getSubCategory().getCategory().getName()) + .subCategoryName(guide.getSubCategory().getName()) + .updatedAt(guide.getUpdatedAt().toString()) + .tags(guideTags.stream() + .map(guideTag -> guideTag.getTag().getName()) + .collect(Collectors.toList())) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/dto/GuideUpdateRequest.java b/src/main/java/com/example/chalpu/guide/dto/GuideUpdateRequest.java new file mode 100644 index 0000000..ede1356 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/dto/GuideUpdateRequest.java @@ -0,0 +1,10 @@ +package com.example.chalpu.guide.dto; + +import lombok.Getter; + +@Getter +public class GuideUpdateRequest { + private String content; + private Long subCategoryId; + private String fileName; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/repository/CategoryRepository.java b/src/main/java/com/example/chalpu/guide/repository/CategoryRepository.java new file mode 100644 index 0000000..69ce5b0 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/repository/CategoryRepository.java @@ -0,0 +1,7 @@ +package com.example.chalpu.guide.repository; + +import com.example.chalpu.guide.domain.Category; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/repository/GuideRepository.java b/src/main/java/com/example/chalpu/guide/repository/GuideRepository.java new file mode 100644 index 0000000..1e98de9 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/repository/GuideRepository.java @@ -0,0 +1,26 @@ +package com.example.chalpu.guide.repository; + +import com.example.chalpu.guide.domain.Guide; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface GuideRepository extends JpaRepository { + @EntityGraph("Guide.withSubCategoryAndCategory") + Optional findByIdAndIsActiveTrue(Long guideId); + + @EntityGraph("Guide.withSubCategoryAndCategory") + Page findAllByIsActiveTrue(Pageable pageable); + + @EntityGraph("Guide.withSubCategoryAndCategory") + Page findBySubCategoryIdAndIsActiveTrue(Long subCategoryId, Pageable pageable); + + // 경량화된 조회 메서드 (연관 엔티티 조회 없음) + @Query("SELECT g FROM Guide g WHERE g.id = :guideId AND g.isActive = true") + Optional findByIdAndIsActiveTrueWithoutJoin(@Param("guideId") Long guideId); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/repository/SubCategoryRepository.java b/src/main/java/com/example/chalpu/guide/repository/SubCategoryRepository.java new file mode 100644 index 0000000..f7a9eb7 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/repository/SubCategoryRepository.java @@ -0,0 +1,15 @@ +package com.example.chalpu.guide.repository; + +import com.example.chalpu.guide.domain.SubCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.repository.EntityGraph; + +import java.util.Optional; + +public interface SubCategoryRepository extends JpaRepository { + + @EntityGraph("SubCategory.withCategory") + Optional findById(Long id); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/guide/service/GuideService.java b/src/main/java/com/example/chalpu/guide/service/GuideService.java new file mode 100644 index 0000000..ef97a72 --- /dev/null +++ b/src/main/java/com/example/chalpu/guide/service/GuideService.java @@ -0,0 +1,206 @@ +package com.example.chalpu.guide.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.NoticeException; +import com.example.chalpu.common.exception.S3Exception; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.guide.domain.Guide; +import com.example.chalpu.guide.domain.SubCategory; +import com.example.chalpu.guide.dto.GuidePresignedUrlRequest; +import com.example.chalpu.guide.dto.GuidePresignedUrlsResponse; +import com.example.chalpu.guide.dto.GuideRegisterRequest; +import com.example.chalpu.guide.dto.GuideResponse; +import com.example.chalpu.guide.dto.GuideUpdateRequest; +import com.example.chalpu.guide.repository.GuideRepository; +import com.example.chalpu.guide.repository.SubCategoryRepository; +import com.example.chalpu.tag.domain.GuideTag; +import com.example.chalpu.tag.domain.Tag; +import com.example.chalpu.tag.repository.GuideTagRepository; +import com.example.chalpu.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Delete; +import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class GuideService { + + private final GuideRepository guideRepository; + private final SubCategoryRepository subCategoryRepository; + private final TagRepository tagRepository; + private final GuideTagRepository guideTagRepository; + private final S3Presigner s3Presigner; + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + public GuidePresignedUrlsResponse getPresignedUrls(GuidePresignedUrlRequest request) { + String uniqueId = UUID.randomUUID().toString(); + String guideS3Key = "guides/" + uniqueId + "-" + request.getFileName() + ".xml"; + String imageS3Key = "guides/images/" + uniqueId + "-" + request.getFileName() + ".png"; + String svgS3Key = "guides/svgs/" + uniqueId + "-" + request.getFileName() + ".svg"; + + String guideUploadUrl = createPresignedUrl(guideS3Key); + String imageUploadUrl = createPresignedUrl(imageS3Key); + String svgUploadUrl = createPresignedUrl(svgS3Key); + + log.info("event=presigned_urls_generated, guide_s3_key={}, image_s3_key={}", guideS3Key, imageS3Key); + + return GuidePresignedUrlsResponse.builder() + .guideS3Key(guideS3Key) + .guideUploadUrl(guideUploadUrl) + .imageS3Key(imageS3Key) + .imageUploadUrl(imageUploadUrl) + .svgS3Key(svgS3Key) + .svgUploadUrl(svgUploadUrl) + .build(); + } + + @Transactional + public GuideResponse registerGuide(GuideRegisterRequest request) { + SubCategory subCategory = subCategoryRepository.findById(request.getSubCategoryId()) + .orElseThrow(() -> new NoticeException(ErrorMessage.SUB_CATEGORY_NOT_FOUND)); + Guide guide = Guide.builder() + .content(request.getContent()) + .guideS3Key(request.getGuideS3Key()) + .imageS3Key(request.getImageS3Key()) + .svgS3Key(request.getSvgS3Key()) + .fileName(request.getFileName()) + .subCategory(subCategory) + .build(); + Guide savedGuide = guideRepository.save(guide); + List tags = findOrCreateTags(request.getTags()); + List guideTags = tags.stream() + .map(tag -> GuideTag.builder().guide(savedGuide).tag(tag).build()) + .collect(Collectors.toList()); + guideTagRepository.saveAll(guideTags); + log.info("event=guide_registered, guide_id={}, sub_category_id={}", savedGuide.getId(), request.getSubCategoryId()); + return GuideResponse.from(savedGuide, guideTags); + } + + @Transactional + public GuideResponse updateGuide(Long guideId, GuideUpdateRequest request) { + Guide guide = guideRepository.findByIdAndIsActiveTrueWithoutJoin(guideId) + .orElseThrow(() -> new NoticeException(ErrorMessage.GUIDE_NOT_FOUND)); + + SubCategory subCategory = null; + if (request.getSubCategoryId() != null) { + subCategory = subCategoryRepository.findById(request.getSubCategoryId()) + .orElseThrow(() -> new NoticeException(ErrorMessage.SUB_CATEGORY_NOT_FOUND)); + } + + guide.update(request.getContent(), request.getFileName(), subCategory); + + List guideTags = guideTagRepository.findByGuideAndIsActiveTrue(guide); + log.info("event=guide_updated, guide_id={}", guideId); + return GuideResponse.from(guide, guideTags); + } + + public GuideResponse findById(Long guideId) { + Guide guide = guideRepository.findByIdAndIsActiveTrue(guideId) + .orElseThrow(() -> new NoticeException(ErrorMessage.GUIDE_NOT_FOUND)); + List guideTags = guideTagRepository.findByGuideAndIsActiveTrue(guide); + return GuideResponse.from(guide, guideTags); + } + + public PageResponse findAll(Pageable pageable) { + Page guidesPage = guideRepository.findAllByIsActiveTrue(pageable); + List guideResponses = guidesPage.getContent().stream() + .map(guide -> GuideResponse.from(guide, guideTagRepository.findByGuideAndIsActiveTrue(guide))) + .collect(Collectors.toList()); + return PageResponse.from(new PageImpl<>(guideResponses, pageable, guidesPage.getTotalElements())); + } + + public PageResponse findAllBySubCategory(Long subCategoryId, Pageable pageable) { + Page guidesPage = guideRepository.findBySubCategoryIdAndIsActiveTrue(subCategoryId, pageable); + List guideResponses = guidesPage.getContent().stream() + .map(guide -> GuideResponse.from(guide, guideTagRepository.findByGuideAndIsActiveTrue(guide))) + .collect(Collectors.toList()); + return PageResponse.from(new PageImpl<>(guideResponses, pageable, guidesPage.getTotalElements())); + } + + @Transactional + public void deleteGuides(List guideIds) { + List guides = guideRepository.findAllById(guideIds); + if (guides.size() != guideIds.size()) { + throw new NoticeException(ErrorMessage.GUIDE_NOT_FOUND); + } + + List s3KeysToDelete = guides.stream() + .flatMap(guide -> java.util.stream.Stream.of(guide.getGuideS3Key(), guide.getImageS3Key(), guide.getSvgS3Key())) + .collect(Collectors.toList()); + deleteS3Objects(s3KeysToDelete); + + guides.forEach(Guide::softDelete); + guideRepository.saveAll(guides); + log.info("event=guides_deleted, guide_ids={}", guideIds); + } + + private String createPresignedUrl(String s3Key) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .putObjectRequest(putObjectRequest) + .build(); + + return s3Presigner.presignPutObject(presignRequest).url().toString(); + } + + private void deleteS3Objects(List s3Keys) { + if (s3Keys == null || s3Keys.isEmpty()) { + return; + } + try { + List toDelete = s3Keys.stream() + .map(key -> ObjectIdentifier.builder().key(key).build()) + .collect(Collectors.toList()); + + DeleteObjectsRequest deleteObjectsRequest = DeleteObjectsRequest.builder() + .bucket(bucketName) + .delete(Delete.builder().objects(toDelete).build()) + .build(); + + s3Client.deleteObjects(deleteObjectsRequest); + } catch (Exception e) { + log.error("event=s3_delete_failed, bucket={}, keys={}, error_message={}", bucketName, s3Keys, e.getMessage(), e); + throw new S3Exception(ErrorMessage.S3_DELETE_FAILED); + } + } + + private List findOrCreateTags(List tagNames) { + if (tagNames == null || tagNames.isEmpty()) { + return new ArrayList<>(); + } + + return tagNames.stream().map(tagName -> + tagRepository.findByNameAndIsActiveTrue(tagName) + .orElseGet(() -> tagRepository.save(Tag.builder().name(tagName).build())) + ).collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/home/controller/HomeController.java b/src/main/java/com/example/chalpu/home/controller/HomeController.java new file mode 100644 index 0000000..8bbdb23 --- /dev/null +++ b/src/main/java/com/example/chalpu/home/controller/HomeController.java @@ -0,0 +1,34 @@ +package com.example.chalpu.home.controller; + +import com.example.chalpu.common.response.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; + +import com.example.chalpu.home.domain.PhotoTip; +import com.example.chalpu.home.dto.PhotoTipDto; + +import java.util.Arrays; +import java.util.List; + +@RestController +@RequestMapping("/api/home") +public class HomeController { + + @GetMapping("/tips") + public ApiResponse> getAllTips() { + return ApiResponse.success(Arrays.stream(PhotoTip.values()) + .map(PhotoTipDto::from) + .toList()); + } + + @GetMapping("/tips/{id}") + public ApiResponse getTipById(@PathVariable String id) { + return ApiResponse.success(PhotoTip.findById(id) + .map(PhotoTipDto::from) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND))); + } +} diff --git a/src/main/java/com/example/chalpu/home/domain/PhotoTip.java b/src/main/java/com/example/chalpu/home/domain/PhotoTip.java new file mode 100644 index 0000000..324ac7f --- /dev/null +++ b/src/main/java/com/example/chalpu/home/domain/PhotoTip.java @@ -0,0 +1,35 @@ +package com.example.chalpu.home.domain; + +import java.util.Arrays; +import java.util.Optional; + +public enum PhotoTip { + ANGLE_45("1", "45도 각도의 마법", "대부분의 음식은 45도 각도에서 촬영하면 입체감이 살아나요. 너무 위에서도, 너무 옆에서도 말고 적당한 각도가 포인트!"), + BACKGROUND("2", "배경 정리하기", "음식 뒤에 보이는 배경을 깔끔하게 정리하세요. 단순한 배경일수록 음식이 더 돋보여요. 흰색 접시나 나무 테이블이 좋아요!"), + COLOR_CONTRAST("3", "색깔 대비 활용하기", "빨간 음식은 흰색이나 검은색 배경에, 하얀 음식은 어두운 배경에 놓으면 더 선명하게 보여요. 색깔 대비를 활용해보세요!"), + PROPS("4", "소품으로 분위기 연출", "젓가락, 냅킨, 작은 반찬 등을 자연스럽게 배치하면 더 풍성해 보여요. 하지만 너무 많이 놓으면 복잡해 보이니 주의하세요!"), + STEAM("5", "스팀과 김 활용하기", "뜨거운 음식에서 올라오는 김을 촬영하면 따뜻함이 전달돼요. 국물 요리나 찜 요리를 찍을 때 김이 보이도록 빠르게 촬영하세요!"), + SECTION("6", "음식의 단면 보여주기", "햄버거, 샌드위치, 케이크 등은 반으로 잘라서 속재료를 보여주면 더 맛있어 보여요. 단면이 깔끔하게 보이도록 날카로운 칼을 사용하세요!"), + AMOUNT("7", "양 조절하기", "접시에 음식을 너무 가득 담지 마세요. 접시의 80% 정도만 채우면 더 고급스러워 보여요. 여백의 미를 활용해보세요!"), + TIME("8", "시간대별 촬영 팁", "오전 10시~오후 2시 사이가 자연광이 가장 좋아요. 흐린 날에도 창가 근처에서 촬영하면 부드러운 빛을 얻을 수 있어요!"); + + private final String id; + private final String title; + private final String text; + + PhotoTip(String id, String title, String text) { + this.id = id; + this.title = title; + this.text = text; + } + + public String getId() { return id; } + public String getTitle() { return title; } + public String getText() { return text; } + + public static Optional findById(String id) { + return Arrays.stream(values()) + .filter(tip -> tip.id.equals(id)) + .findFirst(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/home/dto/PhotoTipDto.java b/src/main/java/com/example/chalpu/home/dto/PhotoTipDto.java new file mode 100644 index 0000000..a5fa5b5 --- /dev/null +++ b/src/main/java/com/example/chalpu/home/dto/PhotoTipDto.java @@ -0,0 +1,9 @@ +package com.example.chalpu.home.dto; + +import com.example.chalpu.home.domain.PhotoTip; + +public record PhotoTipDto(String id, String title, String text) { + public static PhotoTipDto from(PhotoTip tip) { + return new PhotoTipDto(tip.getId(), tip.getTitle(), tip.getText()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/landingPage/controller/LandingController.java b/src/main/java/com/example/chalpu/landingPage/controller/LandingController.java new file mode 100644 index 0000000..8b83044 --- /dev/null +++ b/src/main/java/com/example/chalpu/landingPage/controller/LandingController.java @@ -0,0 +1,42 @@ +package com.example.chalpu.landingPage.controller; + +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.landingPage.domain.Landing; +import com.example.chalpu.landingPage.dto.LandingRequest; +import com.example.chalpu.landingPage.repository.LandingRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +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 org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/landing") +@RequiredArgsConstructor +@Slf4j +public class LandingController { + private final LandingRepository landingRepository; + + @PostMapping("/inquiry") + public ResponseEntity> inquiry(@RequestBody LandingRequest request) { + // Landing 엔티티 생성 + Landing landing = Landing.builder() + .info(request.getInfo()) + .message(request.getMessage()) + .build(); + + // 데이터베이스에 저장 + landingRepository.save(landing); + + // 성공 응답 반환 (ResponseEntity로 감싸기) + return ResponseEntity.ok(ApiResponse.success()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/landingPage/domain/Landing.java b/src/main/java/com/example/chalpu/landingPage/domain/Landing.java new file mode 100644 index 0000000..b819bbe --- /dev/null +++ b/src/main/java/com/example/chalpu/landingPage/domain/Landing.java @@ -0,0 +1,24 @@ +package com.example.chalpu.landingPage.domain; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class Landing { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "inquiry_id") + private Long id; + + private String info; + private String message; +} diff --git a/src/main/java/com/example/chalpu/landingPage/dto/LandingRequest.java b/src/main/java/com/example/chalpu/landingPage/dto/LandingRequest.java new file mode 100644 index 0000000..a1edb70 --- /dev/null +++ b/src/main/java/com/example/chalpu/landingPage/dto/LandingRequest.java @@ -0,0 +1,13 @@ +package com.example.chalpu.landingPage.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class LandingRequest { + private String info; + private String message; +} diff --git a/src/main/java/com/example/chalpu/landingPage/repository/LandingRepository.java b/src/main/java/com/example/chalpu/landingPage/repository/LandingRepository.java new file mode 100644 index 0000000..b547d60 --- /dev/null +++ b/src/main/java/com/example/chalpu/landingPage/repository/LandingRepository.java @@ -0,0 +1,8 @@ +package com.example.chalpu.landingPage.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.chalpu.landingPage.domain.Landing; + +public interface LandingRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/chalpu/menu/controller/MenuController.java b/src/main/java/com/example/chalpu/menu/controller/MenuController.java new file mode 100644 index 0000000..62d433b --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/controller/MenuController.java @@ -0,0 +1,95 @@ +package com.example.chalpu.menu.controller; + +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.menu.dto.MenuRequest; +import com.example.chalpu.menu.dto.MenuResponse; +import com.example.chalpu.menu.service.MenuService; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/stores/{storeId}/menus") +@RequiredArgsConstructor +@Tag(name = "Menu", description = "메뉴 관련 API") +public class MenuController { + + private final MenuService menuService; + + @GetMapping + @Operation( + summary = "메뉴판 목록", + description = """ + 특정 매장의 모든 메뉴판 목록을 조회합니다. + + **페이지네이션 파라미터:** + - `page`: 페이지 번호 (0부터 시작, 기본값: 0) + - `size`: 페이지 크기 (기본값: 20) + - `sort`: 정렬 조건 (선택사항) + + + **요청 예시:** + ``` + GET /api/stores/{storeId}/menus?page=0&size=10&sort=createdAt,desc&sort=menuName,asc + ``` + 위처럼 정렬 조건을 리스트로 줘도 되고 아래처럼 String으로 줘도 됩니다. + ``` + GET /api/stores/{storeId}/menus?page=0&size=10&sort=createdAt,desc + ``` + """, + security = { @SecurityRequirement(name = "bearerAuth") } + ) + public ResponseEntity>> getMenus( + @PathVariable Long storeId, + @PageableDefault(size = 20) Pageable pageable) { + PageResponse menus = menuService.getMenus(storeId, pageable); + return ResponseEntity.ok(ApiResponse.success("메뉴판 목록 조회가 완료되었습니다.", menus)); + } + + @PostMapping + @Operation( + summary = "메뉴판 생성", + description = "새로운 메뉴판을 생성합니다.", + security = { @SecurityRequirement(name = "bearerAuth") } + ) + public ResponseEntity> createMenu( + @PathVariable Long storeId, + @RequestBody MenuRequest menuRequest, + @AuthenticationPrincipal UserDetailsImpl currentUser) { + MenuResponse menu = menuService.createMenu(storeId, menuRequest, currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success("메뉴판 생성이 완료되었습니다.", menu)); + } + + @PutMapping("/{menuId}") + @Operation( + summary = "메뉴판 수정", + description = "기존 메뉴판의 정보를 수정합니다.", + security = { @SecurityRequirement(name = "bearerAuth") } + ) + public ResponseEntity> updateMenu( + @PathVariable Long storeId, + @PathVariable Long menuId, + @RequestBody MenuRequest menuRequest, + @AuthenticationPrincipal UserDetailsImpl currentUser) { + MenuResponse menu = menuService.updateMenu(menuId, menuRequest, currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success("메뉴판 수정이 완료되었습니다.", menu)); + } + + @DeleteMapping("/{menuId}") + @Operation(summary = "메뉴판 삭제", description = "특정 메뉴판을 삭제합니다.") + public ResponseEntity> deleteMenu( + @PathVariable Long storeId, + @PathVariable Long menuId, + @AuthenticationPrincipal UserDetailsImpl currentUser) { + menuService.deleteMenu(menuId, currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success("메뉴판 삭제가 완료되었습니다.", null)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/controller/MenuItemController.java b/src/main/java/com/example/chalpu/menu/controller/MenuItemController.java new file mode 100644 index 0000000..b7489ad --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/controller/MenuItemController.java @@ -0,0 +1,65 @@ +package com.example.chalpu.menu.controller; + +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.menu.dto.MenuItemOrderUpdateRequest; +import com.example.chalpu.menu.dto.MenuItemRequest; +import com.example.chalpu.menu.dto.MenuItemResponse; +import com.example.chalpu.menu.service.MenuItemService; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/menus/{menuId}/items") +public class MenuItemController { + + private final MenuItemService menuItemService; + + @PostMapping + @Operation(summary = "메뉴 아이템 추가", description = "특정 메뉴에 음식 아이템을 추가합니다.") + public ResponseEntity> addMenuItem( + @PathVariable Long menuId, + @RequestBody MenuItemRequest menuItemRequest, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + MenuItemResponse response = menuItemService.addMenuItem(menuId, menuItemRequest, userDetails.getId()); + return ResponseEntity.ok(ApiResponse.success("메뉴 아이템 추가가 완료되었습니다.", response)); + } + + @PatchMapping("/{menuItemId}/order") + @Operation(summary = "메뉴 아이템 표시 순서 수정", description = "특정 메뉴 아이템의 표시 순서를 수정합니다.") + public ResponseEntity> updateMenuItemOrder( + @PathVariable Long menuItemId, + @RequestBody MenuItemOrderUpdateRequest request, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + MenuItemResponse response = menuItemService.updateMenuItemOrder(menuItemId, request, userDetails.getId()); + return ResponseEntity.ok(ApiResponse.success("메뉴 아이템 순서 수정이 완료되었습니다.", response)); + } + + @DeleteMapping("/{menuItemId}") + @Operation(summary = "메뉴 아이템 삭제", description = "특정 메뉴에서 음식 아이템을 제거합니다.") + public ResponseEntity> removeMenuItem( + @PathVariable Long menuId, + @PathVariable Long menuItemId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + menuItemService.removeMenuItem(menuId, menuItemId, userDetails.getId()); + return ResponseEntity.ok(ApiResponse.success("메뉴 아이템 삭제가 완료되었습니다.", null)); + } + + @GetMapping + @Operation(summary = "메뉴 아이템 목록 조회", description = "특정 메뉴에 속한 음식 아이템 목록을 조회합니다.") + public ResponseEntity>> getMenuItems( + @PathVariable Long menuId, + @AuthenticationPrincipal UserDetailsImpl userDetails, + Pageable pageable) { + PageResponse response = menuItemService.getMenuItems(menuId, userDetails.getId(), pageable); + return ResponseEntity.ok(ApiResponse.success("메뉴 아이템 목록 조회가 완료되었습니다.", response)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/domain/Menu.java b/src/main/java/com/example/chalpu/menu/domain/Menu.java new file mode 100644 index 0000000..41b4c91 --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/domain/Menu.java @@ -0,0 +1,59 @@ +package com.example.chalpu.menu.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.menu.dto.MenuRequest; + +@NamedEntityGraph(name = "Menu.withStore", attributeNodes = @NamedAttributeNode("store")) +@Entity +@Table(name = "menus") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = false) +public class Menu extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "menu_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id") + private Store store; + + @Column(length = 100, nullable = false) + private String menuName; + + private String description; + + @Builder.Default + private Boolean isActive = true; + + // == 생성 메서드 == // + public static Menu createMenu(Store store, MenuRequest menuRequest) { + return Menu.builder() + .store(store) + .menuName(menuRequest.getMenuName()) + .description(menuRequest.getDescription()) + .build(); + } + + // == 비즈니스 로직 == // + /** + * 메뉴 정보 수정 + */ + public void updateMenu(MenuRequest menuRequest) { + this.menuName = menuRequest.getMenuName(); + this.description = menuRequest.getDescription(); + } + + /** + * 메뉴 비활성화 (소프트 딜리트) + */ + public void softDelete() { + this.isActive = false; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/domain/MenuItem.java b/src/main/java/com/example/chalpu/menu/domain/MenuItem.java new file mode 100644 index 0000000..88c6ee9 --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/domain/MenuItem.java @@ -0,0 +1,65 @@ +package com.example.chalpu.menu.domain; + +import jakarta.persistence.*; +import lombok.*; +import com.example.chalpu.fooditem.domain.FoodItem; +import com.example.chalpu.menu.dto.MenuItemRequest; + +@NamedEntityGraph( + name = "MenuItem.withMenuAndFoodItem", + attributeNodes = { + @NamedAttributeNode("menu"), + @NamedAttributeNode("foodItem") + } +) +@Entity +@Table(name = "menu_items") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class MenuItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // 고유 PK + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "menu_id", nullable = false) + private Menu menu; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "food_id", nullable = false) + private FoodItem foodItem; + + @Column(name = "display_order", nullable = false) + @Builder.Default + private Integer displayOrder = 1; + + @Builder.Default + private Boolean isActive = true; + + // == 생성 메서드 == // + public static MenuItem createMenuItem(Menu menu, FoodItem foodItem, MenuItemRequest menuItemRequest) { + return MenuItem.builder() + .menu(menu) + .foodItem(foodItem) + .displayOrder(menuItemRequest.getDisplayOrder()) + .isActive(true) + .build(); + } + + // == 비즈니스 로직 == // + /** + * 메뉴 아이템 비활성화 (소프트 딜리트) + */ + public void softDelete() { + this.isActive = false; + } + + /** + * 메뉴 아이템 표시 순서 변경 + */ + public void updateDisplayOrder(Integer displayOrder) { + this.displayOrder = displayOrder; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/dto/MenuItemOrderUpdateRequest.java b/src/main/java/com/example/chalpu/menu/dto/MenuItemOrderUpdateRequest.java new file mode 100644 index 0000000..8adaf3f --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/dto/MenuItemOrderUpdateRequest.java @@ -0,0 +1,17 @@ +package com.example.chalpu.menu.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "메뉴 아이템 표시 순서 수정 요청") +public class MenuItemOrderUpdateRequest { + @Schema(description = "새로운 표시 순서", example = "1", required = true) + private Integer displayOrder; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/dto/MenuItemRequest.java b/src/main/java/com/example/chalpu/menu/dto/MenuItemRequest.java new file mode 100644 index 0000000..9898ed8 --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/dto/MenuItemRequest.java @@ -0,0 +1,22 @@ +package com.example.chalpu.menu.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "메뉴 아이템 생성 요청") +public class MenuItemRequest { + + @Schema(description = "음식 ID", example = "1", required = true) + private Long foodId; + + @Schema(description = "표시 순서", example = "1") + @Builder.Default + private Integer displayOrder = 1; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/dto/MenuItemResponse.java b/src/main/java/com/example/chalpu/menu/dto/MenuItemResponse.java new file mode 100644 index 0000000..f3224f8 --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/dto/MenuItemResponse.java @@ -0,0 +1,41 @@ +package com.example.chalpu.menu.dto; + +import com.example.chalpu.menu.domain.MenuItem; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "메뉴 아이템 응답") +public class MenuItemResponse { + + @Schema(description = "메뉴 아이템 ID", example = "1") + private Long menuItemId; + + @Schema(description = "메뉴판 ID", example = "1") + private Long menuId; + + @Schema(description = "음식 ID", example = "1") + private Long foodId; + + @Schema(description = "음식 이름", example = "김치찌개") + private String foodName; + + @Schema(description = "표시 순서", example = "1") + private Integer displayOrder; + + public static MenuItemResponse from(MenuItem menuItem) { + return MenuItemResponse.builder() + .menuItemId(menuItem.getId()) + .menuId(menuItem.getMenu() != null ? menuItem.getMenu().getId() : null) + .foodId(menuItem.getFoodItem() != null ? menuItem.getFoodItem().getId() : null) + .foodName(menuItem.getFoodItem() != null ? menuItem.getFoodItem().getFoodName() : null) + .displayOrder(menuItem.getDisplayOrder()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/dto/MenuRequest.java b/src/main/java/com/example/chalpu/menu/dto/MenuRequest.java new file mode 100644 index 0000000..337f1d6 --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/dto/MenuRequest.java @@ -0,0 +1,21 @@ +package com.example.chalpu.menu.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "메뉴판 생성 요청") +public class MenuRequest { + + @Schema(description = "메뉴판 이름", example = "런치 메뉴", required = true) + private String menuName; + + @Schema(description = "메뉴판 설명", example = "점심시간 특별 메뉴") + private String description; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/dto/MenuResponse.java b/src/main/java/com/example/chalpu/menu/dto/MenuResponse.java new file mode 100644 index 0000000..b94778b --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/dto/MenuResponse.java @@ -0,0 +1,51 @@ +package com.example.chalpu.menu.dto; + +import com.example.chalpu.menu.domain.Menu; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "메뉴판 응답") +public class MenuResponse { + + @Schema(description = "메뉴판 ID", example = "1") + private Long id; + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "메뉴판 이름", example = "런치 메뉴") + private String menuName; + + @Schema(description = "메뉴판 설명", example = "점심시간 특별 메뉴") + private String description; + + @Schema(description = "활성화 여부", example = "true") + private Boolean isActive; + + @Schema(description = "생성 시간", example = "2024-01-15T09:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정 시간", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; + + public static MenuResponse from(Menu menu) { + return MenuResponse.builder() + .id(menu.getId()) + .storeId(menu.getStore() != null ? menu.getStore().getId() : null) + .menuName(menu.getMenuName()) + .description(menu.getDescription()) + .isActive(menu.getIsActive()) + .createdAt(menu.getCreatedAt()) + .updatedAt(menu.getUpdatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/repository/MenuItemRepository.java b/src/main/java/com/example/chalpu/menu/repository/MenuItemRepository.java new file mode 100644 index 0000000..96d61bc --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/repository/MenuItemRepository.java @@ -0,0 +1,25 @@ +package com.example.chalpu.menu.repository; + +import com.example.chalpu.menu.domain.Menu; +import com.example.chalpu.menu.domain.MenuItem; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface MenuItemRepository extends JpaRepository { + List findByMenuAndIsActiveTrue(Menu menu); + + @EntityGraph(value = "MenuItem.withMenuAndFoodItem") + Optional findByIdAndIsActiveTrue(Long menuItemId); + + @EntityGraph(value = "MenuItem.withMenuAndFoodItem") + Page findByMenuIdAndIsActiveTrue(Long menuId, Pageable pageable); + + List findByFoodItemIdAndIsActiveTrue(Long foodItemId); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/repository/MenuRepository.java b/src/main/java/com/example/chalpu/menu/repository/MenuRepository.java new file mode 100644 index 0000000..ba9fc60 --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/repository/MenuRepository.java @@ -0,0 +1,37 @@ +package com.example.chalpu.menu.repository; + +import com.example.chalpu.menu.domain.Menu; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface MenuRepository extends JpaRepository { + + @EntityGraph(value = "Menu.withStore") + Page findByStoreIdAndIsActiveTrue(Long storeId, Pageable pageable); + + @Query("SELECT m FROM Menu m WHERE m.store.id = :storeId AND m.isActive = true") + Page findByStoreIdAndIsActiveTrueWithoutJoin(@Param("storeId") Long storeId, Pageable pageable); + + @EntityGraph("Menu.withStore") + @Query("SELECT m FROM Menu m WHERE m.store.id = :storeId AND m.isActive = true") + List findAllByStoreIdAndIsActiveTrueWithStore(@Param("storeId") Long storeId); + + @EntityGraph("Menu.withStore") + @Query("SELECT m FROM Menu m WHERE m.id = :menuId AND m.isActive = true") + Optional findByIdAndIsActiveTrueWithStore(@Param("menuId") Long menuId); + + Optional findByIdAndIsActiveTrue(Long menuId); + + // 경량화된 조회 메서드 (연관 엔티티 조회 없음) + @Query("SELECT m FROM Menu m WHERE m.id = :menuId AND m.isActive = true") + Optional findByIdAndIsActiveTrueWithoutJoin(@Param("menuId") Long menuId); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/service/MenuItemService.java b/src/main/java/com/example/chalpu/menu/service/MenuItemService.java new file mode 100644 index 0000000..6bea03d --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/service/MenuItemService.java @@ -0,0 +1,163 @@ +package com.example.chalpu.menu.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.FoodException; +import com.example.chalpu.common.exception.MenuException; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.fooditem.domain.FoodItem; +import com.example.chalpu.fooditem.repository.FoodItemRepository; +import com.example.chalpu.menu.domain.Menu; +import com.example.chalpu.menu.domain.MenuItem; +import com.example.chalpu.menu.dto.MenuItemOrderUpdateRequest; +import com.example.chalpu.menu.dto.MenuItemRequest; +import com.example.chalpu.menu.dto.MenuItemResponse; +import com.example.chalpu.menu.repository.MenuItemRepository; +import com.example.chalpu.menu.repository.MenuRepository; +import com.example.chalpu.store.service.UserStoreRoleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MenuItemService { + + private final MenuItemRepository menuItemRepository; + private final MenuRepository menuRepository; + private final FoodItemRepository foodItemRepository; + private final UserStoreRoleService userStoreRoleService; + + /** + * 메뉴 아이템 추가 + */ + @Transactional + public MenuItemResponse addMenuItem(Long menuId, MenuItemRequest menuItemRequest, Long userId) { + try { + Menu menu = findActiveMenuById(menuId); + validateUserStoreManagement(userId, menu.getStore().getId()); + + FoodItem foodItem = findActiveFoodItemById(menuItemRequest.getFoodId()); + + MenuItem menuItem = MenuItem.createMenuItem(menu, foodItem, menuItemRequest); + MenuItem savedMenuItem = menuItemRepository.save(menuItem); + + log.info("event=menu_item_added, menu_item_id={}, menu_id={}, food_id={}, user_id={}", + savedMenuItem.getId(), menuId, foodItem.getId(), userId); + + return MenuItemResponse.from(savedMenuItem); + } catch (Exception e) { + log.error("event=menu_item_add_failed, menu_id={}, food_id={}, user_id={}, error_message={}", + menuId, menuItemRequest.getFoodId(), userId, e.getMessage(), e); + throw new MenuException(ErrorMessage.MENU_ITEM_CREATE_FAILED); + } + } + + /** + * 메뉴 아이템 표시 순서 수정 + */ + @Transactional + public MenuItemResponse updateMenuItemOrder(Long menuItemId, MenuItemOrderUpdateRequest request, Long userId) { + try { + MenuItem menuItem = findActiveMenuItemById(menuItemId); + validateUserStoreManagement(userId, menuItem.getMenu().getStore().getId()); + + menuItem.updateDisplayOrder(request.getDisplayOrder()); + + log.info("event=menu_item_order_updated, menu_item_id={}, new_order={}, user_id={}", + menuItemId, request.getDisplayOrder(), userId); + + return MenuItemResponse.from(menuItem); + } catch (Exception e) { + log.error("event=menu_item_order_update_failed, menu_item_id={}, user_id={}, error_message={}", + menuItemId, userId, e.getMessage(), e); + throw new MenuException(ErrorMessage.MENU_ITEM_UPDATE_FAILED); + } + } + + /** + * 메뉴 아이템 목록 조회 + */ + public PageResponse getMenuItems(Long menuId, Long userId, Pageable pageable) { + try { + Menu menu = findActiveMenuById(menuId); + validateUserStoreAccess(userId, menu.getStore().getId()); + + Page menuItemPage = menuItemRepository.findByMenuIdAndIsActiveTrue(menuId, pageable); + Page menuItemResponsePage = menuItemPage.map(MenuItemResponse::from); + return PageResponse.from(menuItemResponsePage); + } catch (Exception e) { + log.error("event=menu_items_get_failed, menu_id={}, user_id={}, error_message={}", + menuId, userId, e.getMessage(), e); + throw e; + } + } + + /** + * 메뉴 아이템 삭제 (소프트 딜리트) + */ + @Transactional + public void removeMenuItem(Long menuId, Long menuItemId, Long userId) { + try { + MenuItem menuItem = findActiveMenuItemById(menuItemId); + validateUserStoreManagement(userId, menuItem.getMenu().getStore().getId()); + + if (!menuItem.getMenu().getId().equals(menuId)) { + throw new MenuException(ErrorMessage.MENU_ITEM_NOT_IN_MENU); + } + + menuItem.softDelete(); + + log.info("event=menu_item_removed, menu_item_id={}, menu_id={}, user_id={}", + menuItemId, menuId, userId); + } catch (Exception e) { + log.error("event=menu_item_remove_failed, menu_item_id={}, user_id={}, error_message={}", + menuItemId, userId, e.getMessage(), e); + throw new MenuException(ErrorMessage.MENU_ITEM_DELETE_FAILED); + } + } + + /** + * 메뉴에 속한 모든 메뉴 아이템 비활성화 (소프트 딜리트) + */ + @Transactional + public void softDeleteMenuItemsByMenu(Menu menu) { + List menuItems = menuItemRepository.findByMenuAndIsActiveTrue(menu); + menuItems.forEach(MenuItem::softDelete); + log.info("event=menu_items_deleted_by_menu, menu_id={}, count={}", + menu.getId(), menuItems.size()); + } + + private Menu findActiveMenuById(Long menuId) { + return menuRepository.findByIdAndIsActiveTrue(menuId) + .orElseThrow(() -> new MenuException(ErrorMessage.MENU_NOT_FOUND)); + } + + private FoodItem findActiveFoodItemById(Long foodId) { + return foodItemRepository.findByIdAndIsActiveTrueWithoutJoin(foodId) + .orElseThrow(() -> new FoodException(ErrorMessage.FOOD_NOT_FOUND)); + } + + private MenuItem findActiveMenuItemById(Long menuItemId) { + return menuItemRepository.findByIdAndIsActiveTrue(menuItemId) + .orElseThrow(() -> new MenuException(ErrorMessage.MENU_ITEM_NOT_FOUND)); + } + + private void validateUserStoreAccess(Long userId, Long storeId) { + if (!userStoreRoleService.canUserAccessStore(userId, storeId)) { + throw new MenuException(ErrorMessage.STORE_ACCESS_DENIED); + } + } + + private void validateUserStoreManagement(Long userId, Long storeId) { + if (!userStoreRoleService.canUserManageStore(userId, storeId)) { + throw new MenuException(ErrorMessage.STORE_ACCESS_DENIED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/menu/service/MenuService.java b/src/main/java/com/example/chalpu/menu/service/MenuService.java new file mode 100644 index 0000000..3374f6b --- /dev/null +++ b/src/main/java/com/example/chalpu/menu/service/MenuService.java @@ -0,0 +1,115 @@ +package com.example.chalpu.menu.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.MenuException; +import com.example.chalpu.common.exception.StoreException; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.menu.domain.Menu; +import com.example.chalpu.menu.dto.MenuRequest; +import com.example.chalpu.menu.dto.MenuResponse; +import com.example.chalpu.menu.repository.MenuRepository; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.store.repository.StoreRepository; +import com.example.chalpu.store.service.UserStoreRoleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MenuService { + + private final MenuRepository menuRepository; + private final StoreRepository storeRepository; + private final UserStoreRoleService userStoreRoleService; + private final MenuItemService menuItemService; + + /** + * 매장별 메뉴 목록 조회 (활성 메뉴만) + */ + public PageResponse getMenus(Long storeId, Pageable pageable) { + try { + Page menuPage = menuRepository.findByStoreIdAndIsActiveTrueWithoutJoin(storeId, pageable); + Page menuResponsePage = menuPage.map(MenuResponse::from); + return PageResponse.from(menuResponsePage); + } catch (Exception e) { + log.error("getMenus 실패 - storeId: {}, error: {}", storeId, e.getMessage(), e); + throw new MenuException(ErrorMessage.MENU_NOT_FOUND); + } + } + + /** + * 메뉴 생성 + */ + @Transactional + public MenuResponse createMenu(Long storeId, MenuRequest menuRequest, Long userId) { + validateUserStoreAccess(userId, storeId); + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_NOT_FOUND)); + Menu menu = menuRepository.save(Menu.createMenu(store, menuRequest)); + log.info("event=menu_created, menu_id={}, store_id={}, user_id={}", menu.getId(), storeId, userId); + + return MenuResponse.from(menu); + } + + /** + * 메뉴 수정 + */ + @Transactional + public MenuResponse updateMenu(Long menuId, MenuRequest menuRequest, Long userId) { + Menu menu = findMenuByIdForValidation(menuId); + validateUserStoreAccess(userId, menu.getStore().getId()); + menu.updateMenu(menuRequest); + log.info("event=menu_updated, menu_id={}, user_id={}", menuId, userId); + + return MenuResponse.from(menu); + } + + /** + * 메뉴 삭제 (소프트 딜리트) + */ + @Transactional + public void deleteMenu(Long menuId, Long userId) { + try { + Menu menu = findMenuByIdForValidation(menuId); + validateUserStoreManagement(userId, menu.getStore().getId()); + + // 메뉴 비활성화 + menu.softDelete(); + + // 메뉴 아이템 비활성화 (위임) + menuItemService.softDeleteMenuItemsByMenu(menu); + + log.info("event=menu_deleted, menu_id={}, user_id={}", menuId, userId); + } catch (Exception e) { + log.error("event=menu_deletion_failed, menu_id={}, user_id={}, error_message={}", + menuId, userId, e.getMessage(), e); + throw new MenuException(ErrorMessage.MENU_DELETE_FAILED); + } + } + + + private Menu findMenuByIdForValidation(Long menuId) { + return menuRepository.findByIdAndIsActiveTrueWithoutJoin(menuId) + .orElseThrow(() -> new MenuException(ErrorMessage.MENU_NOT_FOUND)); + } + + private void validateUserStoreManagement(Long userId, Long storeId) { + if (!userStoreRoleService.canUserManageStore(userId, storeId)) { + throw new MenuException(ErrorMessage.STORE_ACCESS_DENIED); + } + } + + private void validateUserStoreAccess(Long userId, Long storeId) { + if (!userStoreRoleService.canUserManageStore(userId, storeId)) { + throw new MenuException(ErrorMessage.STORE_ACCESS_DENIED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/config/OpenApiConfig.java b/src/main/java/com/example/chalpu/oauth/config/OpenApiConfig.java new file mode 100644 index 0000000..5f214e2 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/config/OpenApiConfig.java @@ -0,0 +1,58 @@ +package com.example.chalpu.oauth.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "OAuth2 Module API", + version = "1.0", + description = "API Documentation for OAuth2 Module", + contact = @Contact( + name = "OAuth2 Support", + email = "support@example.com", + url = "https://github.com/yourusername/oauth2-module" + ), + license = @License( + name = "MIT License", + url = "https://opensource.org/licenses/MIT" + ) + ), + servers = { + @Server( + url = "/", + description = "Local Server" + ) + }, + security = { + @SecurityRequirement(name = "bearerAuth") + } +) +@SecurityScheme( + name = "bearerAuth", + description = "JWT Authorization header using Bearer scheme", + scheme = "bearer", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER +) +public class OpenApiConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + + // 필요한 경우 추가 설정을 여기에 구현 +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/config/SecurityConfig.java b/src/main/java/com/example/chalpu/oauth/config/SecurityConfig.java new file mode 100644 index 0000000..2caa34c --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/config/SecurityConfig.java @@ -0,0 +1,93 @@ +package com.example.chalpu.oauth.config; + +import com.example.chalpu.oauth.security.jwt.JwtAuthenticationFilter; +import com.example.chalpu.oauth.security.oauth2.CustomOAuth2UserService; +import com.example.chalpu.oauth.security.oauth2.OAuth2AuthenticationFailureHandler; +import com.example.chalpu.oauth.security.oauth2.OAuth2AuthenticationSuccessHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; + private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // 공개 경로 + .requestMatchers("/", "/login", "/login.html", "/error", "/favicon.ico","/util/token","/actuator/**").permitAll() + .requestMatchers("/index.html","/test.html","/landing/**").permitAll() + .requestMatchers("/api/auth/refresh", "/api/auth/logout", "/api/auth/me").permitAll() + .requestMatchers("/api/test/**").permitAll() // 테스트용 엔드포인트 전체 허용 + .requestMatchers("/admin/cleanup-tokens").permitAll() // 임시 관리자 엔드포인트 + .requestMatchers("/css/**", "/js/**", "/images/**", "/webjars/**").permitAll() + .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + // Apple 테스트 경로 허용 + .requestMatchers("/api/auth/apple/**","/api/auth/naver/**","/api/auth/kakao/**","/api/auth/google/**").permitAll() + .requestMatchers("/apple-login-test.html").permitAll() + // OAuth2 관련 경로 + .requestMatchers("/api/oauth2/**", "/api/login/oauth2/**").permitAll() + // 캠페인 관련 경로 + .requestMatchers("/api/campaigns/types").permitAll() + .requestMatchers("/api/campaigns/sns-platforms").permitAll() + .requestMatchers("/api/campaigns/campaign-platforms").permitAll() + // 공지사항/홈 광고 배너 관련 경로 + .requestMatchers("/api/notices/**").permitAll() + // 나머지는 인증 필요 + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(endpoint -> endpoint.baseUri("/api/oauth2/authorization")) + .redirectionEndpoint(endpoint -> endpoint.baseUri("/api/login/oauth2/code/*")) + .userInfoEndpoint(endpoint -> endpoint.userService(customOAuth2UserService)) + .successHandler(oAuth2AuthenticationSuccessHandler) + .failureHandler(oAuth2AuthenticationFailureHandler) + ) + // JWT 필터 추가 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 웹 + 모바일 모두 허용 + configuration.setAllowedOriginPatterns(Arrays.asList( + "http://localhost:3000", + "http://127.0.0.1:3000", + "*" + )); + + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/com/example/chalpu/oauth/controller/AuthController.java b/src/main/java/com/example/chalpu/oauth/controller/AuthController.java new file mode 100644 index 0000000..36276b5 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/controller/AuthController.java @@ -0,0 +1,50 @@ +package com.example.chalpu.oauth.controller; + +import com.example.chalpu.common.exception.AuthException; +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.UserException; +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.oauth.dto.AccessTokenDTO; +import com.example.chalpu.oauth.dto.RefreshTokenDTO; +import com.example.chalpu.oauth.dto.UserInfoDTO; +import com.example.chalpu.oauth.security.jwt.JwtTokenProvider; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import com.example.chalpu.oauth.service.AuthService; +import com.example.chalpu.oauth.service.RefreshTokenService; +import com.example.chalpu.user.domain.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@Slf4j +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication", description = "인증 관련 API") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/refresh") + public ResponseEntity> refresh(@RequestBody RefreshTokenDTO refreshToken) { + AccessTokenDTO newAccessToken = authService.refreshToken(refreshToken); + return ResponseEntity.ok(ApiResponse.success("토큰 갱신이 완료되었습니다.", newAccessToken)); + } + + @Operation(summary = "로그아웃 처리", description = "현재 로그인한 사용자의 리프레쉬 토큰을 삭제합니다") + @PostMapping("/logout") + public ResponseEntity> logout(@Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser){ + // 현재 사용자의 토큰 삭제 처리 + authService.logout(currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/controller/GoogleAuthController.java b/src/main/java/com/example/chalpu/oauth/controller/GoogleAuthController.java new file mode 100644 index 0000000..712714d --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/controller/GoogleAuthController.java @@ -0,0 +1,109 @@ +package com.example.chalpu.oauth.controller; + +import com.example.chalpu.common.response.ApiResponse; + +import com.example.chalpu.oauth.dto.GoogleLoginRequest; +import com.example.chalpu.oauth.dto.LoginResponse; +import com.example.chalpu.oauth.dto.TokenDTO; +import com.example.chalpu.oauth.security.jwt.JwtTokenProvider; +import com.example.chalpu.oauth.security.oauth2.CustomOAuth2UserService; +import com.example.chalpu.oauth.security.oauth2.user.GoogleOAuth2UserInfo; + +import com.example.chalpu.oauth.security.oauth2.user.OAuth2UserInfo; +import com.example.chalpu.oauth.service.GoogleIdentityTokenService; +import com.example.chalpu.oauth.service.RefreshTokenService; +import com.example.chalpu.user.domain.User; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +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; + +@Slf4j +@RestController +@RequestMapping("/api/auth/google") +@RequiredArgsConstructor +@Tag(name = "Google 인증", description = "Google Sign-In 관련 API") +public class GoogleAuthController { + private final GoogleIdentityTokenService googleIdentityTokenService; + private final CustomOAuth2UserService customOAuth2UserService; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + + @PostMapping("/login") + @Operation( + summary = "Google 모바일 로그인/회원가입", + description = """ + ### Google 모바일 SDK를 통해 로그인 후, 받은 ID Token으로 서버에 로그인/회원가입을 요청하는 API 입니다. + + **모바일 클라이언트 개발 순서:** + 1. 각 플랫폼(iOS/Android)에 맞는 Google Sign-In SDK를 사용하여 사용자의 구글 로그인을 처리합니다. + 2. 로그인 성공 시, Google로부터 **ID Token** 문자열을 발급받습니다. + 3. 발급받은 ID Token을 이 API의 Body에 담아 요청합니다. + 4. 요청 성공 시, 응답으로 받은 **accessToken**과 **refreshToken**을 앱 내 안전한 곳(e.g., Keychain, Keystore)에 저장합니다. + 5. 이후 저희 서비스의 다른 API를 호출할 때는, `Authorization` 헤더에 `Bearer {accessToken}` 형식으로 토큰을 담아 요청합니다. + + --- + + **iOS (Swift) 요청 예시:** + ```swift + guard let url = URL(string: "chalpu.com/api/auth/google/login") else { return } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = ["idToken": "google_id_token_string"] + request.httpBody = try? JSONEncoder().encode(body) + + URLSession.shared.dataTask(with: request) { data, response, error in + // Handle response... + }.resume() + ``` + + **Android (Kotlin with Retrofit) 요청 예시:** + ```kotlin + // Retrofit Interface + interface ApiService { + @POST("api/auth/google/login") + suspend fun loginWithGoogle(@Body body: GoogleLoginRequest): Response + } + + // DTO + data class GoogleLoginRequest(val idToken: String) + + // ViewModel or Repository + suspend fun performGoogleLogin(idToken: String) { + val request = GoogleLoginRequest(idToken = idToken) + val response = yourRetrofitService.loginWithGoogle(request) + // Handle response... + } + ``` + """ + ) + public ResponseEntity> googleLogin(@RequestBody GoogleLoginRequest request) { + // 1. Google ID Token 검증 + GoogleIdToken.Payload payload = googleIdentityTokenService.verify(request.getAccessToken()); + + // 2. OAuth2UserInfo 객체 생성 + OAuth2UserInfo oAuth2UserInfo = new GoogleOAuth2UserInfo(payload); + + // 3. 사용자 조회 또는 생성 (FCM 토큰 등록 포함) + User user = customOAuth2UserService.processOAuth2User(oAuth2UserInfo, "google", + request.getFcmToken(), request.getDeviceType()); + + // 4. Access Token과 Refresh Token 생성 + TokenDTO tokenDTO = jwtTokenProvider.generateTokens(user.getId(), user.getEmail(), user.getRole().name()); + + // 5. Refresh Token DB에 저장 + refreshTokenService.saveRefreshToken(tokenDTO.getRefreshToken(), user.getId()); + + log.info("Google 모바일 로그인 성공: userId={}, email={}, name={}", user.getId(), user.getEmail(), user.getName()); + + return ResponseEntity.ok(ApiResponse.success(new LoginResponse(tokenDTO, user.getId()))); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/controller/KakaoAuthController.java b/src/main/java/com/example/chalpu/oauth/controller/KakaoAuthController.java new file mode 100644 index 0000000..1caeac8 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/controller/KakaoAuthController.java @@ -0,0 +1,108 @@ +package com.example.chalpu.oauth.controller; + +import com.example.chalpu.common.response.ApiResponse; + +import com.example.chalpu.oauth.dto.KakaoLoginRequest; +import com.example.chalpu.oauth.dto.LoginResponse; +import com.example.chalpu.oauth.dto.TokenDTO; +import com.example.chalpu.oauth.security.jwt.JwtTokenProvider; +import com.example.chalpu.oauth.security.oauth2.CustomOAuth2UserService; +import com.example.chalpu.oauth.security.oauth2.user.KakaoOAuth2UserInfo; + +import com.example.chalpu.oauth.security.oauth2.user.OAuth2UserInfo; +import com.example.chalpu.oauth.service.KakaoOAuthService; +import com.example.chalpu.oauth.service.RefreshTokenService; +import com.example.chalpu.user.domain.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/auth/kakao") +@RequiredArgsConstructor +@Tag(name = "Kakao 인증", description = "Kakao 로그인 관련 API") +public class KakaoAuthController { + + private final KakaoOAuthService kakaoOAuthService; + private final CustomOAuth2UserService customOAuth2UserService; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + + @PostMapping("/login") + @Operation( + summary = "Kakao 모바일 로그인/회원가입", + description = """ + ### Kakao 모바일 SDK를 통해 로그인 후, 받은 액세스 토큰으로 서버에 로그인/회원가입을 요청하는 API 입니다. + + **모바일 클라이언트 개발 순서:** + 1. 각 플랫폼(iOS/Android)에 맞는 Kakao SDK를 사용하여 사용자의 카카오 로그인을 처리합니다. + 2. 로그인 성공 시, Kakao로부터 **액세스 토큰** 문자열을 발급받습니다. + 3. 발급받은 액세스 토큰을 이 API의 Body에 담아 요청합니다. + 4. 요청 성공 시, 응답으로 받은 **accessToken**과 **refreshToken**을 앱 내 안전한 곳(e.g., Keychain, Keystore)에 저장합니다. + 5. 이후 저희 서비스의 다른 API를 호출할 때는, `Authorization` 헤더에 `Bearer {accessToken}` 형식으로 토큰을 담아 요청합니다. + + --- + + **iOS (Swift) 요청 예시:** + ```swift + guard let url = URL(string: "chalpu.com/api/auth/kakao/login") else { return } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = ["accessToken": "kakao_access_token_string"] + request.httpBody = try? JSONEncoder().encode(body) + + URLSession.shared.dataTask(with: request) { data, response, error in + // Handle response... + }.resume() + ``` + + **Android (Kotlin with Retrofit) 요청 예시:** + ```kotlin + // Retrofit Interface + interface ApiService { + @POST("api/auth/kakao/login") + suspend fun loginWithKakao(@Body body: KakaoLoginRequest): Response + } + + // DTO + data class KakaoLoginRequest(val accessToken: String) + + // ViewModel or Repository + suspend fun performKakaoLogin(accessToken: String) { + val request = KakaoLoginRequest(accessToken = accessToken) + val response = yourRetrofitService.loginWithKakao(request) + // Handle response... + } + ``` + """ + ) + public ResponseEntity> kakaoMobileLogin(@RequestBody KakaoLoginRequest request) { + // 1. Kakao 액세스 토큰으로 사용자 정보 조회 + Map kakaoUserInfo = kakaoOAuthService.getUserInfo(request.getAccessToken()); + + // 2. OAuth2UserInfo 객체 생성 + OAuth2UserInfo oAuth2UserInfo = new KakaoOAuth2UserInfo(kakaoUserInfo); + + // 3. 사용자 조회 또는 생성 (FCM 토큰 등록 포함) + User user = customOAuth2UserService.processOAuth2User(oAuth2UserInfo, "kakao", + request.getFcmToken(), request.getDeviceType()); + + // 4. Access Token과 Refresh Token 생성 + TokenDTO tokenDTO = jwtTokenProvider.generateTokens(user.getId(), user.getEmail(), user.getRole().name()); + + // 5. Refresh Token DB에 저장 + refreshTokenService.saveRefreshToken(tokenDTO.getRefreshToken(), user.getId()); + + log.info("Kakao 모바일 로그인 성공: userId={}, email={}, name={}", user.getId(), user.getEmail(), user.getName()); + + return ResponseEntity.ok(ApiResponse.success(new LoginResponse(tokenDTO, user.getId()))); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/controller/NaverAuthController.java b/src/main/java/com/example/chalpu/oauth/controller/NaverAuthController.java new file mode 100644 index 0000000..62b2463 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/controller/NaverAuthController.java @@ -0,0 +1,108 @@ +package com.example.chalpu.oauth.controller; + +import com.example.chalpu.common.response.ApiResponse; + +import com.example.chalpu.oauth.dto.LoginResponse; +import com.example.chalpu.oauth.dto.NaverLoginRequest; +import com.example.chalpu.oauth.dto.TokenDTO; +import com.example.chalpu.oauth.security.jwt.JwtTokenProvider; +import com.example.chalpu.oauth.security.oauth2.CustomOAuth2UserService; +import com.example.chalpu.oauth.security.oauth2.user.NaverOAuth2UserInfo; + +import com.example.chalpu.oauth.security.oauth2.user.OAuth2UserInfo; +import com.example.chalpu.oauth.service.NaverOAuthService; +import com.example.chalpu.oauth.service.RefreshTokenService; +import com.example.chalpu.user.domain.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/auth/naver") +@RequiredArgsConstructor +@Tag(name = "Naver 인증", description = "Naver 로그인 관련 API") +public class NaverAuthController { + + private final NaverOAuthService naverOAuthService; + private final CustomOAuth2UserService customOAuth2UserService; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + + @PostMapping("/login") + @Operation( + summary = "Naver 모바일 로그인/회원가입", + description = """ + ### Naver 모바일 SDK를 통해 로그인 후, 받은 액세스 토큰으로 서버에 로그인/회원가입을 요청하는 API 입니다. + + **모바일 클라이언트 개발 순서:** + 1. 각 플랫폼(iOS/Android)에 맞는 Naver SDK를 사용하여 사용자의 네이버 로그인을 처리합니다. + 2. 로그인 성공 시, Naver로부터 **액세스 토큰** 문자열을 발급받습니다. + 3. 발급받은 액세스 토큰을 이 API의 Body에 담아 요청합니다. + 4. 요청 성공 시, 응답으로 받은 **accessToken**과 **refreshToken**을 앱 내 안전한 곳(e.g., Keychain, Keystore)에 저장합니다. + 5. 이후 저희 서비스의 다른 API를 호출할 때는, `Authorization` 헤더에 `Bearer {accessToken}` 형식으로 토큰을 담아 요청합니다. + + --- + + **iOS (Swift) 요청 예시:** + ```swift + guard let url = URL(string: "chalpu.com/api/auth/naver/login") else { return } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = ["accessToken": "naver_access_token_string"] + request.httpBody = try? JSONEncoder().encode(body) + + URLSession.shared.dataTask(with: request) { data, response, error in + // Handle response... + }.resume() + ``` + + **Android (Kotlin with Retrofit) 요청 예시:** + ```kotlin + // Retrofit Interface + interface ApiService { + @POST("api/auth/naver/login") + suspend fun loginWithNaver(@Body body: NaverLoginRequest): Response + } + + // DTO + data class NaverLoginRequest(val accessToken: String) + + // ViewModel or Repository + suspend fun performNaverLogin(accessToken: String) { + val request = NaverLoginRequest(accessToken = accessToken) + val response = yourRetrofitService.loginWithNaver(request) + // Handle response... + } + ``` + """ + ) + public ResponseEntity> naverMobileLogin(@RequestBody NaverLoginRequest request) { + // 1. Naver 액세스 토큰으로 사용자 정보 조회 + Map naverUserInfo = naverOAuthService.getUserInfo(request.getAccessToken()); + + // 2. OAuth2UserInfo 객체 생성 + OAuth2UserInfo oAuth2UserInfo = new NaverOAuth2UserInfo(naverUserInfo); + + // 3. 사용자 조회 또는 생성 (FCM 토큰 등록 포함) + User user = customOAuth2UserService.processOAuth2User(oAuth2UserInfo, "naver", + request.getFcmToken(), request.getDeviceType()); + + // 4. Access Token과 Refresh Token 생성 + TokenDTO tokenDTO = jwtTokenProvider.generateTokens(user.getId(), user.getEmail(), user.getRole().name()); + + // 5. Refresh Token DB에 저장 + refreshTokenService.saveRefreshToken(tokenDTO.getRefreshToken(), user.getId()); + + log.info("Naver 모바일 로그인 성공: userId={}, email={}, name={}", user.getId(), user.getEmail(), user.getName()); + + return ResponseEntity.ok(ApiResponse.success(new LoginResponse(tokenDTO, user.getId()))); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/dto/AccessTokenDTO.java b/src/main/java/com/example/chalpu/oauth/dto/AccessTokenDTO.java new file mode 100644 index 0000000..9caef03 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/dto/AccessTokenDTO.java @@ -0,0 +1,16 @@ +package com.example.chalpu.oauth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "액세스 토큰 정보 (토큰 갱신용)") +public class AccessTokenDTO { + + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; +} diff --git a/src/main/java/com/example/chalpu/oauth/dto/GoogleLoginRequest.java b/src/main/java/com/example/chalpu/oauth/dto/GoogleLoginRequest.java new file mode 100644 index 0000000..1071a13 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/dto/GoogleLoginRequest.java @@ -0,0 +1,22 @@ +package com.example.chalpu.oauth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "Google 모바일 로그인 요청 Body") +public class GoogleLoginRequest { + + @Schema(description = "Google Sign-In SDK로부터 발급받은 ID Token 문자열", + example = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzRhYmM1Njc4Z...", + required = true) + private String accessToken; + + @Schema(description = "FCM 토큰", example = "fcm_token_here") + private String fcmToken; + + @Schema(description = "디바이스 타입", example = "android") + private String deviceType; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/dto/KakaoLoginRequest.java b/src/main/java/com/example/chalpu/oauth/dto/KakaoLoginRequest.java new file mode 100644 index 0000000..cce610e --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/dto/KakaoLoginRequest.java @@ -0,0 +1,20 @@ +package com.example.chalpu.oauth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "Kakao 로그인 요청 DTO") +public class KakaoLoginRequest { + + @Schema(description = "Kakao 액세스 토큰", example = "kakao_access_token_string") + private String accessToken; + + @Schema(description = "FCM 토큰", example = "fcm_token_here") + private String fcmToken; + + @Schema(description = "디바이스 타입", example = "android") + private String deviceType; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/dto/LoginResponse.java b/src/main/java/com/example/chalpu/oauth/dto/LoginResponse.java new file mode 100644 index 0000000..c99708a --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/dto/LoginResponse.java @@ -0,0 +1,12 @@ +package com.example.chalpu.oauth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +public class LoginResponse { + private TokenDTO tokens; + private Long userId; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/dto/NaverLoginRequest.java b/src/main/java/com/example/chalpu/oauth/dto/NaverLoginRequest.java new file mode 100644 index 0000000..092b6a9 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/dto/NaverLoginRequest.java @@ -0,0 +1,20 @@ +package com.example.chalpu.oauth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "Naver 로그인 요청 DTO") +public class NaverLoginRequest { + + @Schema(description = "Naver 액세스 토큰", example = "naver_access_token_string") + private String accessToken; + + @Schema(description = "FCM 토큰", example = "fcm_token_here") + private String fcmToken; + + @Schema(description = "디바이스 타입", example = "android or ios or web") + private String deviceType; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/dto/RefreshTokenDTO.java b/src/main/java/com/example/chalpu/oauth/dto/RefreshTokenDTO.java new file mode 100644 index 0000000..dc56705 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/dto/RefreshTokenDTO.java @@ -0,0 +1,17 @@ +package com.example.chalpu.oauth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "리프레쉬 토큰 정보 (토큰 갱신용)") +public class RefreshTokenDTO { + + @Schema(description = "리프레쉬 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; +} + diff --git a/src/main/java/com/example/chalpu/oauth/dto/TokenDTO.java b/src/main/java/com/example/chalpu/oauth/dto/TokenDTO.java new file mode 100644 index 0000000..00e787e --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/dto/TokenDTO.java @@ -0,0 +1,19 @@ +package com.example.chalpu.oauth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "인증 토큰 정보") +public class TokenDTO { + + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; +} diff --git a/src/main/java/com/example/chalpu/oauth/dto/UserInfoDTO.java b/src/main/java/com/example/chalpu/oauth/dto/UserInfoDTO.java new file mode 100644 index 0000000..5ace5fa --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/dto/UserInfoDTO.java @@ -0,0 +1,42 @@ +package com.example.chalpu.oauth.dto; + +import com.example.chalpu.oauth.model.AuthProvider; +import com.example.chalpu.user.domain.User; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "사용자 정보") +public class UserInfoDTO { + + @Schema(description = "사용자 ID", example = "1") + private Long id; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/images/profile.jpg") + private String picture; + + @Schema(description = "인증 제공자", example = "GOOGLE", enumAsRef = true) + private AuthProvider provider; + + public static UserInfoDTO fromEntity(User user) { + return UserInfoDTO.builder() + .id(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .picture(user.getPicture()) + .provider(user.getProvider()) + .build(); + } +} diff --git a/src/main/java/com/example/chalpu/oauth/model/AuthProvider.java b/src/main/java/com/example/chalpu/oauth/model/AuthProvider.java new file mode 100644 index 0000000..dd15756 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/model/AuthProvider.java @@ -0,0 +1,47 @@ +package com.example.chalpu.oauth.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "인증 제공자 유형") +public enum AuthProvider { + @Schema(description = "Google 로그인") GOOGLE, + @Schema(description = "Kakao 로그인") KAKAO, + @Schema(description = "Naver 로그인") NAVER; + /** + * 문자열로부터 AuthProvider를 반환합니다. + * @param value 인증 제공자 문자열 (대소문자 무관) + * @return 해당하는 AuthProvider + * @throws IllegalArgumentException 지원하지 않는 인증 제공자인 경우 + */ + public static AuthProvider from(String value) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("인증 제공자가 비어있습니다."); + } + + for (AuthProvider provider : values()) { + if (provider.name().equalsIgnoreCase(value.trim())) { + return provider; + } + } + + throw new IllegalArgumentException("지원하지 않는 인증 제공자입니다: " + value); + } + + /** + * 인증 제공자가 유효한지 확인합니다. + * @param value 인증 제공자 문자열 + * @return 유효 여부 + */ + public static boolean isValid(String value) { + if (value == null || value.trim().isEmpty()) { + return false; + } + + for (AuthProvider provider : values()) { + if (provider.name().equalsIgnoreCase(value.trim())) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/example/chalpu/oauth/model/RefreshToken.java b/src/main/java/com/example/chalpu/oauth/model/RefreshToken.java new file mode 100644 index 0000000..c91c5b8 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/model/RefreshToken.java @@ -0,0 +1,33 @@ +package com.example.chalpu.oauth.model; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import com.example.chalpu.user.domain.User; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "auth_token") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RefreshToken extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "refresh_token", nullable = false) + private String refreshToken; + + @OneToOne + @JoinColumn(name = "user_id", nullable = false) + @JsonIgnore + private User user; + + // 리프레시 토큰 업데이트 메서드 + public void updateRefreshToken(String newRefreshToken) { + this.refreshToken = newRefreshToken; + } +} diff --git a/src/main/java/com/example/chalpu/oauth/repository/RefreshTokenRepository.java b/src/main/java/com/example/chalpu/oauth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..da2587e --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/repository/RefreshTokenRepository.java @@ -0,0 +1,25 @@ +package com.example.chalpu.oauth.repository; + +import com.example.chalpu.oauth.model.RefreshToken; +import com.example.chalpu.user.domain.User; +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 java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + @Query("SELECT rt FROM RefreshToken rt WHERE rt.refreshToken = :refreshToken AND rt.user.isActive = true") + Optional findByRefreshToken(@Param("refreshToken") String refreshToken); + + @Query("SELECT rt FROM RefreshToken rt WHERE rt.user = :user AND rt.user.isActive = true") + Optional findByUser(@Param("user") User user); + + @Query("SELECT rt FROM RefreshToken rt WHERE rt.user.id = :userId AND rt.user.isActive = true") + Optional findByUserId(@Param("userId") Long userId); + + void deleteByUser(User user); + void deleteByUserId(Long userId); +} diff --git a/src/main/java/com/example/chalpu/oauth/security/jwt/CustomUserDetailsService.java b/src/main/java/com/example/chalpu/oauth/security/jwt/CustomUserDetailsService.java new file mode 100644 index 0000000..8690c5c --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/jwt/CustomUserDetailsService.java @@ -0,0 +1,28 @@ +package com.example.chalpu.oauth.security.jwt; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.UserException; +import com.example.chalpu.user.domain.User; +import com.example.chalpu.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findActiveByEmail(email) + .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); + + return UserDetailsImpl.build(user); + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/chalpu/oauth/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..e04044f --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,96 @@ +package com.example.chalpu.oauth.security.jwt; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.response.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider tokenProvider; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + if(SecurityContextHolder.getContext().getAuthentication() != null){ + filterChain.doFilter(request, response); + return; + } + String jwt = getJwtFromRequest(request); + + if (StringUtils.hasText(jwt)) { + // 토큰 유효성 검증 + tokenProvider.validateToken(jwt); + + // Access Token인지 확인 + if (tokenProvider.isAccessToken(jwt)) { + Long userId = tokenProvider.getUserIdFromToken(jwt); + String email = tokenProvider.getEmailFromToken(jwt); + + String role = tokenProvider.getRoleFromToken(jwt) != null ? tokenProvider.getRoleFromToken(jwt) : "ROLE_USER"; + UserDetails userDetails = new UserDetailsImpl( + userId, + email, + null, // name - JWT에서 추출하지 않음 + null, // picture - JWT에서 추출하지 않음 + null, // provider - JWT에서 추출하지 않음 + List.of(new SimpleGrantedAuthority(role)), + null // attributes - JWT 토큰에는 OAuth2 provider의 추가 속성 정보가 없음 + ); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("사용자 인증 설정 완료: userId = {}, email = {}, role = {}", userId, email, role); + } + } + } catch (Exception ex) { + log.error("event=jwt_auth_failed, error_message={}", ex.getMessage(), ex); + sendErrorResponse(response, ErrorMessage.AUTH_INVALID_TOKEN); + return; + } + + filterChain.doFilter(request, response); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + private void sendErrorResponse(HttpServletResponse response, ErrorMessage errorMessage) throws IOException { + response.setStatus(errorMessage.getHttpStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + + ApiResponse apiResponse = ApiResponse.error(errorMessage.getHttpStatus().value(), errorMessage.getMessage()); + String jsonResponse = objectMapper.writeValueAsString(apiResponse); + + response.getWriter().write(jsonResponse); + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/jwt/JwtTokenProvider.java b/src/main/java/com/example/chalpu/oauth/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..8428140 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/jwt/JwtTokenProvider.java @@ -0,0 +1,151 @@ +package com.example.chalpu.oauth.security.jwt; + +import com.example.chalpu.common.exception.AuthException; +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.oauth.dto.TokenDTO; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +@Slf4j +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + private final long accessTokenValidityInMinutes; + private final long refreshTokenValidityInDays; + + public JwtTokenProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token.validity-in-minutes:120}") long accessTokenValidityInMinutes, + @Value("${jwt.refresh-token.validity-in-days:14}") long refreshTokenValidityInDays) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessTokenValidityInMinutes = accessTokenValidityInMinutes; + this.refreshTokenValidityInDays = refreshTokenValidityInDays; + } + + /** + * Access Token과 Refresh Token을 함께 생성하여 TokenDTO로 반환 + */ + public TokenDTO generateTokens(Long userId, String email, String role) { + String accessToken = generateAccessToken(userId, email, role); + String refreshToken = generateRefreshToken(userId); + + log.info("토큰 생성 완료: 사용자 ID = {}", userId); + + return new TokenDTO(accessToken, refreshToken); + } + + // Access Token 생성 (15분) + public String generateAccessToken(Long userId, String email, String role) { + Instant now = Instant.now(); + Instant expiration = now.plus(accessTokenValidityInMinutes, ChronoUnit.MINUTES); + + return Jwts.builder().subject(userId.toString()) + .claim("email", email) + .claim("role", role) + .claim("type", "access") + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + } + + // Refresh Token 생성 (14일) + public String generateRefreshToken(Long userId) { + Instant now = Instant.now(); + Instant expiration = now.plus(refreshTokenValidityInDays, ChronoUnit.DAYS); + + return Jwts.builder().subject(userId.toString()) + .claim("type", "refresh").issuedAt(Date.from(now)).expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + } + + /** + * 테스트용 Access Token 생성 (10년) + */ + public String generateTestAccessToken(Long userId, String email, String role) { + Instant now = Instant.now(); + Instant expiration = now.plus(3650, ChronoUnit.DAYS); // 10 years + + return Jwts.builder() + .subject(userId.toString()) + .claim("email", email) + .claim("role", role) + .claim("type", "access") + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + } + + // 토큰에서 사용자 ID 추출 + public Long getUserIdFromToken(String token) { + return Long.parseLong(getClaimsFromToken(token).getSubject()); + } + + // 토큰에서 이메일 추출 (Access Token만) + public String getEmailFromToken(String token) { + return getClaimsFromToken(token).get("email", String.class); + } + + // 토큰에서 role 추출 (Access Token만) + public String getRoleFromToken(String token) { + return getClaimsFromToken(token).get("role", String.class); + } + + // 토큰 타입 확인 + public String getTokenType(String token) { + return getClaimsFromToken(token).get("type", String.class); + } + + // 토큰 유효성 검증 + public void validateToken(String token) { + try { + getClaimsFromToken(token); + } catch (ExpiredJwtException e) { + log.error("event=jwt_token_expired, token={}", token.substring(0, Math.min(token.length(), 20)) + "..."); + throw new AuthException(ErrorMessage.JWT_EXPIRED); + } catch (MalformedJwtException e) { + log.error("event=jwt_token_malformed, error_message={}", e.getMessage()); + throw new AuthException(ErrorMessage.JWT_MALFORMED); + } catch (UnsupportedJwtException e) { + log.error("event=jwt_token_unsupported, error_message={}", e.getMessage()); + throw new AuthException(ErrorMessage.JWT_UNSUPPORTED); + } catch (IllegalArgumentException e) { + log.error("event=jwt_token_claims_empty, error_message={}", e.getMessage()); + throw new AuthException(ErrorMessage.JWT_CLAIMS_EMPTY); + } catch (JwtException e) { + log.error("event=jwt_token_invalid_signature, error_message={}", e.getMessage()); + throw new AuthException(ErrorMessage.JWT_INVALID_SIGNATURE); + } + } + + // Access Token인지 확인 + public boolean isAccessToken(String token) { + return "access".equals(getTokenType(token)); + } + + // Refresh Token인지 확인 + public boolean isRefreshToken(String token) { + return "refresh".equals(getTokenType(token)); + } + + // 토큰에서 Claims 추출 + private Claims getClaimsFromToken(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/jwt/UserDetailsImpl.java b/src/main/java/com/example/chalpu/oauth/security/jwt/UserDetailsImpl.java new file mode 100644 index 0000000..6836488 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/jwt/UserDetailsImpl.java @@ -0,0 +1,88 @@ +package com.example.chalpu.oauth.security.jwt; + +import com.example.chalpu.oauth.model.AuthProvider; +import com.example.chalpu.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Getter +@AllArgsConstructor +public class UserDetailsImpl implements UserDetails, OAuth2User { + private Long id; + private String email; + private String name; + private String picture; + private AuthProvider provider; + private Collection authorities; + private Map attributes; + + public static UserDetailsImpl build(User user) { + // 사용자의 실제 role 사용 + String role = user.getRole() != null ? user.getRole().name() : "ROLE_USER"; + List authorities = List.of(new SimpleGrantedAuthority(role)); + + return new UserDetailsImpl( + user.getId(), + user.getEmail(), + user.getName(), + user.getPicture(), + user.getProvider(), + authorities, + null + ); + } + + public static UserDetailsImpl build(User user, Map attributes) { + UserDetailsImpl userDetails = UserDetailsImpl.build(user); + userDetails.attributes = attributes; + return userDetails; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return null; // OAuth 사용자는 비밀번호 없음 + } + + @Override + public String getUsername() { + return email; + } + + @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/com/example/chalpu/oauth/security/oauth2/CustomOAuth2UserService.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..c780786 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,218 @@ +package com.example.chalpu.oauth.security.oauth2; + +import com.example.chalpu.common.exception.AuthException; +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.OAuth2AuthenticationProcessingException; +import com.example.chalpu.fcm.dto.FCMTokenRequest; +import com.example.chalpu.fcm.service.FCMTokenService; +import com.example.chalpu.oauth.model.AuthProvider; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import com.example.chalpu.oauth.security.oauth2.user.OAuth2UserInfo; +import com.example.chalpu.oauth.security.oauth2.user.OAuth2UserInfoFactory; +import com.example.chalpu.user.domain.Role; +import com.example.chalpu.user.domain.User; +import com.example.chalpu.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import com.example.chalpu.user.service.UserService; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * 커스텀 OAuth2 사용자 서비스 + * Custom OAuth2 user service that processes OAuth2 login and user registration + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private final UserService userService; + private final FCMTokenService fcmTokenService; + + /** + * OAuth2 사용자 정보 로드 + * Load OAuth2 user information from the provider + */ + @Override + public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest); + + try { + return processOAuth2User(oAuth2UserRequest, oAuth2User); + } catch (OAuth2AuthenticationProcessingException ex) { + throw ex; + } catch (Exception ex) { + log.error("OAuth2 인증 처리 중 오류 발생: {}", ex.getMessage()); + throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause()); + } + } + + /** + * OAuth2 사용자 정보 처리 + * Process OAuth2 user information and register or update user + */ + private OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) { + // 등록 ID 및 사용자 정보 가져오기 (Get registration ID and user info) + String registrationId = oAuth2UserRequest.getClientRegistration().getRegistrationId(); + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes()); + + // 이메일 확인 (Validate email) + validateEmail(oAuth2UserInfo); + + // 사용자 조회 또는 생성 (Find or create user) + User user = findOrCreateUser(oAuth2UserInfo, registrationId); + + // UserDetails 객체 생성 및 반환 (Build and return UserDetails) + return UserDetailsImpl.build(user, oAuth2User.getAttributes()); + } + + /** + * OAuth2 제공자별 사용자 정보 처리 (통합 메서드) + * @param userInfo OAuth2 사용자 정보 + * @param providerName 제공자 이름 (google, kakao, naver, apple 등) + * @param fcmToken FCM 토큰 (선택사항) + * @param deviceType 디바이스 타입 (선택사항) + * @return 처리된 사용자 엔티티 + */ + public User processOAuth2User(OAuth2UserInfo userInfo, String providerName, String fcmToken, String deviceType) { + try { + validateEmail(userInfo); + User user = findOrCreateUser(userInfo, providerName); + + // FCM 토큰 등록 + registerFCMTokenIfPresent(user.getId(), fcmToken, deviceType); + + return user; + } catch (OAuth2AuthenticationProcessingException ex) { + throw ex; + } catch (Exception ex) { + log.error("{} 사용자 처리 중 오류 발생: {}", providerName, ex.getMessage()); + throw new AuthException(ErrorMessage.INTERNAL_SERVER_ERROR); + } + } + + /** + * OAuth2 제공자별 사용자 정보 처리 (기존 호환성을 위한 오버로드) + * @param userInfo OAuth2 사용자 정보 + * @param providerName 제공자 이름 (google, kakao, naver, apple 등) + * @return 처리된 사용자 엔티티 + */ + public User processOAuth2User(OAuth2UserInfo userInfo, String providerName) { + return processOAuth2User(userInfo, providerName, null, null); + } + + /** + * 이메일 유효성 검증 + * Validate email from OAuth2 provider + */ + private void validateEmail(OAuth2UserInfo oAuth2UserInfo) { + if (!StringUtils.hasText(oAuth2UserInfo.getEmail())) { + throw new OAuth2AuthenticationProcessingException("OAuth2 제공자로부터 이메일을 찾을 수 없습니다"); + } + } + + /** + * 사용자 조회 또는 생성 + * Find existing user or create a new one + */ + private User findOrCreateUser(OAuth2UserInfo oAuth2UserInfo, String registrationId) { + AuthProvider provider = AuthProvider.valueOf(registrationId.toUpperCase()); + + // 삭제된 사용자 포함하여 이메일로 사용자 조회 + Optional userOptional = userRepository.findByEmailWithDeleted(oAuth2UserInfo.getEmail()); + + // 이메일로 사용자를 찾았으면 + if (userOptional.isPresent()) { + User existingUser = userOptional.get(); + + // 같은 제공자가 아니면 오류 발생 + if (existingUser.getProvider() != null && !existingUser.getProvider().equals(provider)) { + throw new OAuth2AuthenticationProcessingException( + String.format("이미 %s 계정으로 가입되어 있습니다. %s 계정으로 로그인해 주세요.", + existingUser.getProvider(), existingUser.getProvider()) + ); + } + + // 탈퇴한 사용자인지 확인 + if (existingUser.getDeletedAt() != null) { + // 탈퇴한 지 30일이 지났는지 확인 + if (existingUser.getDeletedAt().plusDays(30).isAfter(LocalDateTime.now())) { + // 30일이 지났으면 계정 활성화 및 정보 업데이트 (재가입 처리) + userService.activateUser(existingUser.getId()); + log.info("event=탈퇴 후 30일이 경과하여 계정을 복구합니다: {}", existingUser.getEmail()); + } else { + // 30일이 지나지 않았으면 오류 발생 + throw new AuthException(ErrorMessage.USER_DEACTIVATED_REJOIN_UNAVAILABLE); + } + } + + // 정보 업데이트 후 반환 + return updateExistingUser(existingUser, oAuth2UserInfo); + } else { + // 이메일로 사용자를 찾지 못했으면 새로 등록 + return registerNewUser(provider, oAuth2UserInfo); + } + } + + /** + * 신규 사용자 (role_user) 등록 + * Register a new user from OAuth2 information + */ + private User registerNewUser(AuthProvider provider, OAuth2UserInfo oAuth2UserInfo) { + User user = User.builder() + .email(oAuth2UserInfo.getEmail()) + .name(oAuth2UserInfo.getName()) + .picture(oAuth2UserInfo.getImageUrl()) + .socialId(oAuth2UserInfo.getId()) + .providerUserId(oAuth2UserInfo.getId()) + .provider(provider) + .role(Role.ROLE_USER) + .build(); + + log.info("새 OAuth2 사용자 등록: {}", user.getEmail()); + return userRepository.save(user); + } + + /** + * 기존 사용자 정보 업데이트 + * Update existing user information + */ + private User updateExistingUser(User existingUser, OAuth2UserInfo oAuth2UserInfo) { + existingUser.updateOAuth2Info(oAuth2UserInfo.getName(), oAuth2UserInfo.getImageUrl()); + + log.info("기존 OAuth2 사용자 정보 업데이트: {}", existingUser.getEmail()); + return userRepository.save(existingUser); + } + + /** + * FCM 토큰 등록 (선택사항) + */ + private void registerFCMTokenIfPresent(Long userId, String fcmToken, String deviceType) { + if (fcmToken != null && !fcmToken.trim().isEmpty() && + deviceType != null && !deviceType.trim().isEmpty() && + !"web".equalsIgnoreCase(deviceType)) { + try { + FCMTokenRequest fcmRequest = FCMTokenRequest.builder() + .userId(userId) + .fcmToken(fcmToken) + .deviceType(deviceType) + .build(); + fcmTokenService.registerOrUpdateToken(fcmRequest); + log.info("FCM 토큰 등록 성공: userId={}, deviceType={}", userId, deviceType); + } catch (Exception e) { + log.warn("FCM 토큰 등록 실패 (로그인은 성공): userId={}, error={}", userId, e.getMessage()); + } + } + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/oauth2/OAuth2AuthenticationFailureHandler.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/OAuth2AuthenticationFailureHandler.java new file mode 100644 index 0000000..2adf439 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/OAuth2AuthenticationFailureHandler.java @@ -0,0 +1,36 @@ +package com.example.chalpu.oauth.security.oauth2; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + @Value("${oauth2.redirect.failure-url}") + private String redirectFailureUrl; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException { + + String errorMessage = exception.getLocalizedMessage(); + log.error("OAuth2 인증 실패: {}", errorMessage, exception); + + // UriComponentsBuilder를 사용하여 일관된 방식으로 리다이렉트 URL 생성 + String targetUrl = UriComponentsBuilder.fromUriString(redirectFailureUrl) + .queryParam("message", "OAuth 인증에 실패했습니다: " + errorMessage) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/oauth2/OAuth2AuthenticationSuccessHandler.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 0000000..827edb5 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,74 @@ +package com.example.chalpu.oauth.security.oauth2; + +import com.example.chalpu.oauth.dto.TokenDTO; +import com.example.chalpu.oauth.security.jwt.JwtTokenProvider; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import com.example.chalpu.oauth.service.AuthService; +import com.example.chalpu.oauth.service.RefreshTokenService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider authService; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + + @Value("${oauth2.redirect.success-url}") + private String redirectSuccessUrl; + @Value("${oauth2.redirect.failure-url}") + private String redirectFailureUrl; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + try { + UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); + + // AuthService를 통해 토큰 생성 (UserDetailsImpl 직접 전달) + String role = userDetails.getAuthorities().iterator().next().getAuthority(); + TokenDTO tokenDTO = jwtTokenProvider.generateTokens(userDetails.getId(), userDetails.getEmail(), role); + + // Refresh Token DB에 저장 + refreshTokenService.saveRefreshToken(tokenDTO.getRefreshToken(), userDetails.getId()); + + log.info("OAuth2 로그인 성공: userId={}, email={}, provider={}", + userDetails.getId(), userDetails.getEmail(), userDetails.getProvider()); + + // 리다이렉트 URL에 TokenDTO 정보 및 userId 포함 + String targetUrl = UriComponentsBuilder.fromUriString(redirectSuccessUrl) + .queryParam("accessToken", tokenDTO.getAccessToken()) + .queryParam("refreshToken", tokenDTO.getRefreshToken()) + .queryParam("userId", userDetails.getId()) + .build().toUriString(); + + // 리다이렉트 수행 + getRedirectStrategy().sendRedirect(request, response, targetUrl); + + } catch (Exception e) { + log.error("OAuth2 인증 성공 처리 중 오류 발생: {}", e.getMessage(), e); + + // 오류 발생 시 실패 URL로 리다이렉트 + String errorUrl = UriComponentsBuilder.fromUriString(redirectFailureUrl) + .queryParam("error", URLEncoder.encode("로그인 처리 중 오류가 발생했습니다.", StandardCharsets.UTF_8)) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, errorUrl); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/security/oauth2/exception/OAuth2AuthenticationProcessingException.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/exception/OAuth2AuthenticationProcessingException.java new file mode 100644 index 0000000..9b394ff --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/exception/OAuth2AuthenticationProcessingException.java @@ -0,0 +1,9 @@ +package com.example.chalpu.oauth.security.oauth2.exception; + +import org.springframework.security.core.AuthenticationException; + +public class OAuth2AuthenticationProcessingException extends AuthenticationException { + public OAuth2AuthenticationProcessingException(String msg) { + super(msg); + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/oauth2/user/AppleOAuth2UserInfo.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/AppleOAuth2UserInfo.java new file mode 100644 index 0000000..e2e78e3 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/AppleOAuth2UserInfo.java @@ -0,0 +1,39 @@ +package com.example.chalpu.oauth.security.oauth2.user; + +import org.springframework.util.StringUtils; + +import java.util.Map; + +public class AppleOAuth2UserInfo extends OAuth2UserInfo { + + public AppleOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return (String) attributes.get("sub"); + } + + @Override + public String getName() { + // Apple은 사용자 이름을 Identity Token에 포함하지 않음 + // 필요시 이메일 앞부분을 사용하거나 기본값 설정 + String email = getEmail(); + if (email != null && email.contains("@")) { + return email.substring(0, email.indexOf("@")); + } + return "Apple User"; + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getImageUrl() { + // Apple은 프로필 이미지를 제공하지 않음 + return null; + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/oauth2/user/GoogleOAuth2UserInfo.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/GoogleOAuth2UserInfo.java new file mode 100644 index 0000000..4b5dbda --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/GoogleOAuth2UserInfo.java @@ -0,0 +1,30 @@ +package com.example.chalpu.oauth.security.oauth2.user; + +import java.util.Map; + +public class GoogleOAuth2UserInfo extends OAuth2UserInfo { + + public GoogleOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return (String) attributes.get("sub"); + } + + @Override + public String getName() { + return (String) attributes.get("name"); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getImageUrl() { + return (String) attributes.get("picture"); + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/oauth2/user/KakaoOAuth2UserInfo.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/KakaoOAuth2UserInfo.java new file mode 100644 index 0000000..570b8eb --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/KakaoOAuth2UserInfo.java @@ -0,0 +1,42 @@ +package com.example.chalpu.oauth.security.oauth2.user; + +import java.util.Map; + +public class KakaoOAuth2UserInfo extends OAuth2UserInfo { + + public KakaoOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return attributes.get("id").toString(); + } + + @Override + public String getName() { + Map properties = (Map) attributes.get("properties"); + if (properties == null) { + return null; + } + return (String) properties.get("nickname"); + } + + @Override + public String getEmail() { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + if (kakaoAccount == null) { + return null; + } + return (String) kakaoAccount.get("email"); + } + + @Override + public String getImageUrl() { + Map properties = (Map) attributes.get("properties"); + if (properties == null) { + return null; + } + return (String) properties.get("profile_image"); + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/oauth2/user/NaverOAuth2UserInfo.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/NaverOAuth2UserInfo.java new file mode 100644 index 0000000..ec66d80 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/NaverOAuth2UserInfo.java @@ -0,0 +1,46 @@ +package com.example.chalpu.oauth.security.oauth2.user; + +import java.util.Map; + +public class NaverOAuth2UserInfo extends OAuth2UserInfo { + + public NaverOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + Map response = (Map) attributes.get("response"); + if (response == null) { + return null; + } + return (String) response.get("id"); + } + + @Override + public String getName() { + Map response = (Map) attributes.get("response"); + if (response == null) { + return null; + } + return (String) response.get("name"); + } + + @Override + public String getEmail() { + Map response = (Map) attributes.get("response"); + if (response == null) { + return null; + } + return (String) response.get("email"); + } + + @Override + public String getImageUrl() { + Map response = (Map) attributes.get("response"); + if (response == null) { + return null; + } + return (String) response.get("profile_image"); + } +} diff --git a/src/main/java/com/example/chalpu/oauth/security/oauth2/user/OAuth2UserInfo.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/OAuth2UserInfo.java new file mode 100644 index 0000000..9290a5e --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/OAuth2UserInfo.java @@ -0,0 +1,22 @@ +package com.example.chalpu.oauth.security.oauth2.user; + +import lombok.Getter; + +import java.util.Map; + +@Getter +public abstract class OAuth2UserInfo { + protected Map attributes; + + public OAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + public abstract String getId(); + + public abstract String getName(); + + public abstract String getEmail(); + + public abstract String getImageUrl(); +} diff --git a/src/main/java/com/example/chalpu/oauth/security/oauth2/user/OAuth2UserInfoFactory.java b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/OAuth2UserInfoFactory.java new file mode 100644 index 0000000..2113ab2 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/security/oauth2/user/OAuth2UserInfoFactory.java @@ -0,0 +1,33 @@ +package com.example.chalpu.oauth.security.oauth2.user; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.OAuthException; +import com.example.chalpu.oauth.model.AuthProvider; + +import java.util.Map; + +public class OAuth2UserInfoFactory { + + public static OAuth2UserInfo getOAuth2UserInfo(AuthProvider authProvider, Map attributes) { + switch (authProvider) { + case GOOGLE: + return new GoogleOAuth2UserInfo(attributes); + case KAKAO: + return new KakaoOAuth2UserInfo(attributes); + case NAVER: + return new NaverOAuth2UserInfo(attributes); + default: + throw new OAuthException(ErrorMessage.OAUTH_PROVIDER_NOT_SUPPORTED); + } + } + + // 기존 메서드도 유지 (하위 호환성) + public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map attributes) { + try { + AuthProvider authProvider = AuthProvider.valueOf(registrationId.toUpperCase()); + return getOAuth2UserInfo(authProvider, attributes); + } catch (IllegalArgumentException e) { + throw new OAuthException(ErrorMessage.OAUTH_PROVIDER_NOT_SUPPORTED); + } + } +} diff --git a/src/main/java/com/example/chalpu/oauth/service/AuthService.java b/src/main/java/com/example/chalpu/oauth/service/AuthService.java new file mode 100644 index 0000000..58b5f37 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/service/AuthService.java @@ -0,0 +1,72 @@ +package com.example.chalpu.oauth.service; + +import com.example.chalpu.common.exception.AuthException; +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.UserException; + +import com.example.chalpu.oauth.dto.AccessTokenDTO; +import com.example.chalpu.oauth.dto.RefreshTokenDTO; +import com.example.chalpu.oauth.dto.TokenDTO; +import com.example.chalpu.oauth.security.jwt.JwtTokenProvider; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import com.example.chalpu.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final RefreshTokenService refreshTokenService; + private final JwtTokenProvider tokenProvider; + + @Transactional + public AccessTokenDTO refreshToken(RefreshTokenDTO refreshToken) { + String oldRefreshToken = refreshToken.getRefreshToken(); + try { + // 토큰 검증 로직을 AuthService로 이동 + User user = validateAndGetUser(oldRefreshToken); + + String newAccessToken = tokenProvider.generateAccessToken(user.getId(), user.getEmail(), user.getRole().name()); + + log.info("event=access_token_refreshed, user_id={}", user.getId()); + return new AccessTokenDTO(newAccessToken); + } catch (Exception e) { + log.error("event=access_token_refresh_failed, error_message={}", e.getMessage(), e); + throw e; + } + } + + @Transactional + public void logout(Long userId) { + try { + refreshTokenService.deleteRefreshTokenByUserId(userId); + log.info("event=user_logged_out, user_id={}", userId); + } catch (Exception e) { + log.error("event=user_logout_failed, user_id={}, error_message={}", userId, e.getMessage(), e); + throw e; + } + } + + + + // 공통 검증 로직 + private User validateAndGetUser(String refreshToken) { + if (refreshToken == null) { + throw new AuthException(ErrorMessage.AUTH_REFRESH_TOKEN_NOT_FOUND); + } + + if (!refreshTokenService.validateRefreshToken(refreshToken)) { + throw new AuthException(ErrorMessage.AUTH_INVALID_REFRESH_TOKEN); + } + + return refreshTokenService.getUserByRefreshToken(refreshToken) + .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); + } +} + diff --git a/src/main/java/com/example/chalpu/oauth/service/GoogleIdentityTokenService.java b/src/main/java/com/example/chalpu/oauth/service/GoogleIdentityTokenService.java new file mode 100644 index 0000000..5185409 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/service/GoogleIdentityTokenService.java @@ -0,0 +1,43 @@ +package com.example.chalpu.oauth.service; + +import com.example.chalpu.common.exception.AuthException; +import com.example.chalpu.common.exception.ErrorMessage; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +@Slf4j +@Service +public class GoogleIdentityTokenService { + + private final GoogleIdTokenVerifier verifier; + + public GoogleIdentityTokenService(@Value("${spring.security.oauth2.client.registration.google.client-id}") String clientId) { + this.verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) + .setAudience(Collections.singletonList(clientId)) + .build(); + log.info("GoogleIdTokenVerifier initialized for client-id: {}", clientId); + } + + public GoogleIdToken.Payload verify(String idTokenString) { + try { + GoogleIdToken idToken = verifier.verify(idTokenString); + if (idToken == null) { + log.error("Invalid Google ID token. The token couldn't be verified."); + throw new AuthException(ErrorMessage.AUTH_INVALID_TOKEN); + } + log.info("Google ID Token verification successful for email: {}", idToken.getPayload().getEmail()); + return idToken.getPayload(); + } catch (Exception e) { + log.error("Google ID Token verification failed: {}", e.getMessage(), e); + throw new AuthException(ErrorMessage.AUTH_INVALID_TOKEN); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/service/KakaoOAuthService.java b/src/main/java/com/example/chalpu/oauth/service/KakaoOAuthService.java new file mode 100644 index 0000000..c20fa33 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/service/KakaoOAuthService.java @@ -0,0 +1,50 @@ +package com.example.chalpu.oauth.service; + +import com.example.chalpu.common.exception.AuthException; +import com.example.chalpu.common.exception.ErrorMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KakaoOAuthService { + + private final RestTemplate restTemplate; + + public Map getUserInfo(String accessToken) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + "https://kapi.kakao.com/v2/user/me", + HttpMethod.GET, + entity, + Map.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + log.info("Kakao 사용자 정보 조회 성공"); + return response.getBody(); + } else { + log.error("Kakao 사용자 정보 조회 실패: {}", response.getStatusCode()); + throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); + } + + } catch (Exception e) { + log.error("Kakao 사용자 정보 조회 중 오류 발생: {}", e.getMessage(), e); + throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/service/NaverOAuthService.java b/src/main/java/com/example/chalpu/oauth/service/NaverOAuthService.java new file mode 100644 index 0000000..27c4170 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/service/NaverOAuthService.java @@ -0,0 +1,50 @@ +package com.example.chalpu.oauth.service; + +import com.example.chalpu.common.exception.AuthException; +import com.example.chalpu.common.exception.ErrorMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NaverOAuthService { + + private final RestTemplate restTemplate; + + public Map getUserInfo(String accessToken) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + "https://openapi.naver.com/v1/nid/me", + HttpMethod.GET, + entity, + Map.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + log.info("Naver 사용자 정보 조회 성공"); + return response.getBody(); + } else { + log.error("Naver 사용자 정보 조회 실패: {}", response.getStatusCode()); + throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); + } + + } catch (Exception e) { + log.error("Naver 사용자 정보 조회 중 오류 발생: {}", e.getMessage(), e); + throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/oauth/service/RefreshTokenService.java b/src/main/java/com/example/chalpu/oauth/service/RefreshTokenService.java new file mode 100644 index 0000000..9637375 --- /dev/null +++ b/src/main/java/com/example/chalpu/oauth/service/RefreshTokenService.java @@ -0,0 +1,94 @@ +package com.example.chalpu.oauth.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.RefreshTokenException; +import com.example.chalpu.common.exception.UserException; +import com.example.chalpu.oauth.model.RefreshToken; +import com.example.chalpu.oauth.repository.RefreshTokenRepository; +import com.example.chalpu.oauth.security.jwt.JwtTokenProvider; +import com.example.chalpu.user.domain.User; +import com.example.chalpu.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + + @Transactional(readOnly = true) + public boolean validateRefreshToken(String tokenValue) { + // 1. DB에 토큰이 존재하는지 확인 + Optional refreshTokenOpt = refreshTokenRepository.findByRefreshToken(tokenValue); + + if (refreshTokenOpt.isEmpty()) { + log.error("존재하지 않는 Refresh Token: {}", tokenValue); + return false; + } + + jwtTokenProvider.validateToken(tokenValue); + + // 3. Refresh Token 타입인지 확인 + if (!jwtTokenProvider.isRefreshToken(tokenValue)) { + log.error("Refresh Token이 아닌 토큰: {}", tokenValue); + return false; + } + + return true; + } + + @Transactional(readOnly = true) + public Optional getUserByRefreshToken(String tokenValue) { + return refreshTokenRepository.findByRefreshToken(tokenValue) + .map(RefreshToken::getUser) + .filter(user -> user.getIsActive()); // 활성 사용자만 반환 + } + + @Transactional + public void saveRefreshToken(String refreshToken, Long userId) { + try { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); + + // 기존 리프레시 토큰이 있으면 업데이트, 없으면 새로 저장 + refreshTokenRepository.findByUser(user) + .ifPresentOrElse( + token -> { + token.updateRefreshToken(refreshToken); + log.info("event=refresh_token_updated, user_id={}", userId); + }, + () -> { + RefreshToken newRefreshToken = RefreshToken.builder() + .refreshToken(refreshToken) + .user(user) + .build(); + refreshTokenRepository.save(newRefreshToken); + log.info("event=refresh_token_created, user_id={}", userId); + } + ); + } catch (Exception e) { + log.error("event=refresh_token_save_failed, user_id={}, error_message={}", userId, e.getMessage(), e); + throw new RefreshTokenException(ErrorMessage.REFRESH_TOKEN_SAVE_ERROR); + } + } + + @Transactional + public void deleteRefreshTokenByUserId(Long userId) { + try { + refreshTokenRepository.deleteByUserId(userId); + log.info("event=refresh_token_deleted, user_id={}", userId); + } catch (Exception e) { + log.error("event=refresh_token_delete_failed, user_id={}, error_message={}", userId, e.getMessage(), e); + throw new RefreshTokenException(ErrorMessage.REFRESH_TOKEN_DELETE_ERROR); + } + } +} diff --git a/src/main/java/com/example/chalpu/photo/controller/PhotoController.java b/src/main/java/com/example/chalpu/photo/controller/PhotoController.java new file mode 100644 index 0000000..f89291b --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/controller/PhotoController.java @@ -0,0 +1,127 @@ +package com.example.chalpu.photo.controller; + +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import com.example.chalpu.photo.dto.*; +import com.example.chalpu.photo.service.PhotoService; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/photos") +@RequiredArgsConstructor +@Tag(name = "사진 API", description = "사진 관련 API") +public class PhotoController { + + private final PhotoService photoService; + + @Operation(summary = "Presigned URL 생성", description = """ + 클라이언트가 AWS S3에 파일을 직접 업로드하기 위해 사용하는 Presigned URL을 생성합니다. + + **클라이언트 처리 순서:** + 1. 이 API를 호출하여 `presignedUrl`과 `s3Key`를 받습니다. + 2. 받은 `presignedUrl`을 목적지로, 업로드할 파일의 원본 데이터를 body에 담아 **HTTP PUT** 요청을 보냅니다. + - **주의:** `Content-Type` 헤더에 반드시 실제 파일의 MIME 타입(예: `image/jpeg`)을 포함해야 합니다. + 3. S3 업로드가 성공(HTTP 200 OK)하면, `/api/photos/register` API를 호출하여 업로드 완료 사실을 서버에 알립니다. + - 이때 응답으로 받았던 `s3Key`와 파일의 원본 이름 등 필요한 메타데이터를 함께 전송합니다. + """) + @PostMapping("/presigned-url") + public ApiResponse generatePresignedUrl( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody PhotoUploadRequest request) { + Long userId = userDetails.getId(); + return ApiResponse.success(photoService.generatePresignedUrl(userId, request)); + } + + @Operation(summary = "임시 폴더 Presigned URL 생성", description = "tmp 폴더에 업로드하기 위한 Presigned URL을 생성합니다.") + @PostMapping("/tmp/presigned-url") + public ApiResponse generateTmpPresignedUrl( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody PhotoUploadRequest request) { + Long userId = userDetails.getId(); + return ApiResponse.success(photoService.generateTmpPresignedUrl(userId, request)); + } + + @Operation(summary = "사진 정보 등록", description = "S3에 업로드 완료 후, 파일 메타데이터를 서버에 등록합니다.") + @PostMapping("/register") + public ApiResponse registerPhoto( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody PhotoRegisterRequest request) { + Long userId = userDetails.getId(); + return ApiResponse.success(photoService.registerPhoto(userId, request)); + } + + @Operation(summary = "가게별 사진 목록 조회", description = "특정 가게에 속한 사진 목록을 페이지네이션하여 조회합니다.") + @GetMapping("/store/{storeId}") + public ApiResponse> getPhotosByStore( + @PathVariable Long storeId, + @PageableDefault(size = 10) Pageable pageable) { + return ApiResponse.success(photoService.getPhotosByStore(storeId, pageable)); + } + + @Operation(summary = "음식별 사진 목록 조회", description = "특정 음식에 속한 사진 목록을 페이지네이션하여 조회합니다.") + @GetMapping("/food-item/{foodItemId}") + public ApiResponse> getPhotosByFoodItem( + @PathVariable Long foodItemId, + @PageableDefault(size = 10) Pageable pageable) { + return ApiResponse.success(photoService.getPhotosByFoodItem(foodItemId, pageable)); + } + + @Operation(summary = "대표 사진 지정", description = "특정 음식에 대표 사진을 지정합니다.") + @PatchMapping("/featured") + public ApiResponse setFeaturedPhoto( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody PhotoSetFeaturedRequest request) { + photoService.setFeaturedPhoto(userDetails.getId(), request); + return ApiResponse.success(); + } + + @Operation(summary = "사진 상세 조회", description = "특정 사진의 상세 정보를 조회합니다.") + @GetMapping("/{photoId}") + public ApiResponse getPhoto(@PathVariable Long photoId) { + return ApiResponse.success(photoService.getPhoto(photoId)); + } + + @Operation(summary = "사진 삭제", description = "특정 사진을 삭제합니다.") + @DeleteMapping("/{photoId}") + public ApiResponse deletePhoto( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long photoId) { + Long userId = userDetails.getId(); + photoService.deletePhoto(userId, photoId); + return ApiResponse.success(); + } + + @Operation(summary = "배경제거 사진 처리", description = "포토룸 API를 이용해 배경을 제거한 사진을 바이너리로 반환합니다. 실제 저장은 별도 API를 사용하세요.") + @PostMapping(value = "/background-removal", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity processPhotoWithBackgroundRemoval( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam("file") MultipartFile file, + @ModelAttribute PhotoBackgroundRemovalRequest request) { + + // MultipartFile에서 자동으로 파일명과 크기 설정 + if (request.getFileName() == null || request.getFileName().isEmpty()) { + request.setFileName(file.getOriginalFilename()); + } + + byte[] processedImage = photoService.processBackgroundRemovalAsBytes(userDetails.getId(), file, request); + + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + .header("Content-Disposition", "inline; filename=\"" + request.getFileName() + "_nuggi.png\"") + .body(processedImage); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/domain/Photo.java b/src/main/java/com/example/chalpu/photo/domain/Photo.java new file mode 100644 index 0000000..5ba7dd8 --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/domain/Photo.java @@ -0,0 +1,63 @@ +package com.example.chalpu.photo.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import com.example.chalpu.user.domain.User; +import com.example.chalpu.fooditem.domain.FoodItem; +import com.example.chalpu.store.domain.Store; + +import jakarta.persistence.*; +import lombok.*; +import java.sql.Timestamp; + +@NamedEntityGraph( + name = "Photo.withAll", + attributeNodes = { + @NamedAttributeNode("user"), + @NamedAttributeNode("store"), + @NamedAttributeNode("foodItem") + } +) +@Entity +@Table(name = "photos") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = false) +public class Photo extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "photo_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id") + private Store store; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "food_id") + private FoodItem foodItem; + + @Column(length = 500, nullable = false, unique = true) + private String s3Key; + + @Column(length = 255, nullable = false) + private String fileName; + + private String filter; + private Integer fileSize; + private Integer imageWidth; + private Integer imageHeight; + + @Builder.Default + private Boolean isActive = true; + + public void softDelete() { + this.isActive = false; + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/dto/PhotoBackgroundRemovalRequest.java b/src/main/java/com/example/chalpu/photo/dto/PhotoBackgroundRemovalRequest.java new file mode 100644 index 0000000..240e741 --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/dto/PhotoBackgroundRemovalRequest.java @@ -0,0 +1,12 @@ +package com.example.chalpu.photo.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class PhotoBackgroundRemovalRequest { + private String fileName; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/dto/PhotoBackgroundRemovalResponse.java b/src/main/java/com/example/chalpu/photo/dto/PhotoBackgroundRemovalResponse.java new file mode 100644 index 0000000..0ff9358 --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/dto/PhotoBackgroundRemovalResponse.java @@ -0,0 +1,11 @@ +package com.example.chalpu.photo.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class PhotoBackgroundRemovalResponse { + private String processedImageBase64; + private String originalFileName; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/dto/PhotoPresignedUrlResponse.java b/src/main/java/com/example/chalpu/photo/dto/PhotoPresignedUrlResponse.java new file mode 100644 index 0000000..01789c9 --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/dto/PhotoPresignedUrlResponse.java @@ -0,0 +1,21 @@ +package com.example.chalpu.photo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Presigned URL 발급 응답") +public class PhotoPresignedUrlResponse { + + @Schema(description = "S3에 업로드할 때 사용할, 유효기간이 설정된 URL") + private String presignedUrl; + + @Schema(description = "업로드 후 S3에 저장될 파일의 고유 키. 업로드 완료 API 호출 시 필요.", example = "photos/stores/1/a1b2c3d4-e5f6-7890-1234-567890abcdef.jpg") + private String s3Key; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/dto/PhotoRegisterRequest.java b/src/main/java/com/example/chalpu/photo/dto/PhotoRegisterRequest.java new file mode 100644 index 0000000..b907812 --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/dto/PhotoRegisterRequest.java @@ -0,0 +1,37 @@ +package com.example.chalpu.photo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "S3 업로드 완료 후 사진 정보 등록 요청") +public class PhotoRegisterRequest { + + @Schema(description = "Presigned URL 발급 시 받았던 S3 파일 키", example = "photos/stores/1/a1b2c3d4-e5f6-7890-1234-567890abcdef.jpg") + private String s3Key; + + @Schema(description = "업로드한 파일의 원본 이름", example = "kimchi-stew.jpg") + private String fileName; + + @Schema(description = "사진이 속한 가게의 ID", example = "1") + private Long storeId; + + @Schema(description = "사진이 속한 음식 아이템의 ID (선택)", example = "10") + private Long foodItemId; + + // 필요에 따라 파일 사이즈, 가로/세로 길이 등 추가 메타데이터 포함 가능 + @Schema(description = "파일 크기 (bytes)", example = "3145728") + private Integer fileSize; + + @Schema(description = "이미지 가로 길이 (px)", example = "1920") + private Integer imageWidth; + + @Schema(description = "이미지 세로 길이 (px)", example = "1080") + private Integer imageHeight; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/dto/PhotoResponse.java b/src/main/java/com/example/chalpu/photo/dto/PhotoResponse.java new file mode 100644 index 0000000..84f9f20 --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/dto/PhotoResponse.java @@ -0,0 +1,66 @@ +package com.example.chalpu.photo.dto; + +import com.example.chalpu.photo.domain.Photo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "사진 응답") +public class PhotoResponse { + + @Schema(description = "사진 ID", example = "1") + private Long photoId; + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "음식 ID", example = "1") + private Long foodItemId; + + @Schema(description = "CloudFront를 통해 접근 가능한 이미지 전체 URL", example = "https://cdn.chalpu.com/photos/stores/1/image.jpg") + private String imageUrl; + + @Schema(description = "원본 파일명", example = "image.jpg") + private String fileName; + + @Schema(description = "파일 크기 (bytes)", example = "1024") + private Integer fileSize; + + @Schema(description = "이미지 너비 (px)", example = "1920") + private Integer imageWidth; + + @Schema(description = "이미지 높이 (px)", example = "1080") + private Integer imageHeight; + + @Schema(description = "생성 시간", example = "2024-01-15T09:30:00") + private LocalDateTime createdAt; + + public static PhotoResponse from(Photo photo, String cloudfrontDomain) { + return PhotoResponse.builder() + .photoId(photo.getId()) + .storeId(photo.getStore() != null ? photo.getStore().getId() : null) + .foodItemId(photo.getFoodItem() != null ? photo.getFoodItem().getId() : null) + .imageUrl(buildFullUrl(cloudfrontDomain, photo.getS3Key())) + .fileName(photo.getFileName()) + .fileSize(photo.getFileSize()) + .imageWidth(photo.getImageWidth()) + .imageHeight(photo.getImageHeight()) + .createdAt(photo.getCreatedAt()) + .build(); + } + + private static String buildFullUrl(String cloudfrontDomain, String s3Key) { + if (cloudfrontDomain == null || s3Key == null) { + return null; + } + return cloudfrontDomain.endsWith("/") ? cloudfrontDomain + s3Key : cloudfrontDomain + "/" + s3Key; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/dto/PhotoSetFeaturedRequest.java b/src/main/java/com/example/chalpu/photo/dto/PhotoSetFeaturedRequest.java new file mode 100644 index 0000000..e40b6fb --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/dto/PhotoSetFeaturedRequest.java @@ -0,0 +1,21 @@ +package com.example.chalpu.photo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "대표 사진 지정") +public class PhotoSetFeaturedRequest { + @Schema(description = "대표 사진 ID", example = "1") + private Long photoId; + + @Schema(description = "음식 ID", example = "1") + private Long foodItemId; +} diff --git a/src/main/java/com/example/chalpu/photo/dto/PhotoUploadRequest.java b/src/main/java/com/example/chalpu/photo/dto/PhotoUploadRequest.java new file mode 100644 index 0000000..12db157 --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/dto/PhotoUploadRequest.java @@ -0,0 +1,18 @@ +package com.example.chalpu.photo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "사진 업로드를 위한 Presigned URL 요청") +public class PhotoUploadRequest { + + @Schema(description = "업로드할 파일의 원본 이름", example = "kimchi-stew.jpg") + private String fileName; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/repository/PhotoRepository.java b/src/main/java/com/example/chalpu/photo/repository/PhotoRepository.java new file mode 100644 index 0000000..1ba4300 --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/repository/PhotoRepository.java @@ -0,0 +1,62 @@ +package com.example.chalpu.photo.repository; + +import com.example.chalpu.photo.domain.Photo; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PhotoRepository extends JpaRepository { + + @EntityGraph(value = "Photo.withAll") + List findByFoodItemIdAndIsActiveTrue(Long foodId); + + @Query("SELECT p FROM Photo p WHERE p.store.id = :storeId AND p.isActive = true") + List findByStoreIdAndIsActiveTrueWithoutJoin(@Param("storeId") Long storeId); + + @EntityGraph(value = "Photo.withAll") + Page findByFoodItemIdAndIsActiveTrue(Long foodId, Pageable pageable); + + @EntityGraph(value = "Photo.withAll") + Page findByStoreIdAndIsActiveTrue(Long storeId, Pageable pageable); + + @Query("SELECT p FROM Photo p WHERE p.store.id = :storeId AND p.isActive = true") + Page findByStoreIdAndIsActiveTrueWithoutJoin(@Param("storeId") Long storeId, Pageable pageable); + + @Query("SELECT p FROM Photo p WHERE p.foodItem.id = :foodId AND p.isActive = true") + Page findByFoodItemIdAndIsActiveTrueWithoutJoin(@Param("foodId") Long foodId, Pageable pageable); + + Optional findByIdAndIsActiveTrue(Long id); + + // 경량화된 조회 메서드 (연관 엔티티 조회 없음) + @Query("SELECT p FROM Photo p WHERE p.id = :id AND p.isActive = true") + Optional findByIdAndIsActiveTrueWithoutJoin(@Param("id") Long id); + + /** + * FoodItem 삭제 시 연관된 Photo들 소프트 딜리트 + * @param foodItemId 음식 아이템 ID + */ + @Modifying + @Query("UPDATE Photo p SET p.isActive = false WHERE p.foodItem.id = :foodItemId") + void softDeleteByFoodItemId(@Param("foodItemId") Long foodItemId); + + /** + * User 삭제 시 연관된 Photo들 소프트 딜리트 + * @param userId 사용자 ID + */ + @Modifying + @Query("UPDATE Photo p SET p.isActive = false WHERE p.user.id = :userId") + void softDeleteByUserId(@Param("userId") Long userId); + + @Modifying + @Query("UPDATE Photo p SET p.isActive = true WHERE p.user.id = :userId") + void activateByUserId(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/service/PhotoRoomService.java b/src/main/java/com/example/chalpu/photo/service/PhotoRoomService.java new file mode 100644 index 0000000..343a109 --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/service/PhotoRoomService.java @@ -0,0 +1,71 @@ +package com.example.chalpu.photo.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.PhotoException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PhotoRoomService { + + private final RestTemplate restTemplate; + + @Value("${photoroom.api.url:https://sdk.photoroom.com/v1/segment}") + private String photoRoomApiUrl; + + @Value("${photoroom.api.key}") + private String photoRoomApiKey; + + public byte[] removeBackground(MultipartFile imageFile) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + headers.set("X-Api-Key", photoRoomApiKey); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("image_file", new ByteArrayResource(imageFile.getBytes()) { + @Override + public String getFilename() { + return imageFile.getOriginalFilename(); + } + }); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.postForEntity( + photoRoomApiUrl, + requestEntity, + byte[].class + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + log.error("event=photoroom_api_failed, status_code={}", response.getStatusCode()); + throw new PhotoException(ErrorMessage.PHOTO_BACKGROUND_REMOVAL_FAILED); + } + + log.info("event=background_removed_successfully, file_name={}", imageFile.getOriginalFilename()); + return response.getBody(); + + } catch (IOException e) { + log.error("event=photoroom_api_io_error, file_name={}, error_message={}", + imageFile.getOriginalFilename(), e.getMessage(), e); + throw new PhotoException(ErrorMessage.PHOTO_BACKGROUND_REMOVAL_FAILED); + } catch (Exception e) { + log.error("event=photoroom_api_unexpected_error, file_name={}, error_message={}", + imageFile.getOriginalFilename(), e.getMessage(), e); + throw new PhotoException(ErrorMessage.PHOTO_BACKGROUND_REMOVAL_FAILED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/photo/service/PhotoService.java b/src/main/java/com/example/chalpu/photo/service/PhotoService.java new file mode 100644 index 0000000..f63e4bb --- /dev/null +++ b/src/main/java/com/example/chalpu/photo/service/PhotoService.java @@ -0,0 +1,258 @@ +package com.example.chalpu.photo.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.PhotoException; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.photo.domain.Photo; +import com.example.chalpu.photo.dto.*; +import com.example.chalpu.photo.repository.PhotoRepository; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.store.repository.StoreRepository; +import com.example.chalpu.user.domain.User; +import com.example.chalpu.user.repository.UserRepository; +import com.example.chalpu.store.service.UserStoreRoleService; +import com.example.chalpu.fooditem.domain.FoodItem; +import com.example.chalpu.fooditem.repository.FoodItemRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URL; +import java.time.Duration; +import java.util.Objects; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PhotoService { + + private final PhotoRepository photoRepository; + private final StoreRepository storeRepository; + private final S3Presigner s3Presigner; + private final S3Client s3Client; + private final UserStoreRoleService userStoreRoleService; + private final FoodItemRepository foodItemRepository; + private final PhotoRoomService photoRoomService; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.cloudfront.domain}") + private String cloudfrontDomain; + + public PhotoPresignedUrlResponse generatePresignedUrl(final Long userId, final PhotoUploadRequest request) { + try { + String s3Key = createS3Key(request.getFileName()); + URL presignedUrl = createPresignedUrl(s3Key); + log.info("event=presigned_url_generated, user_id={}, file_name={}, s3_key={}", + userId, request.getFileName(), s3Key); + return PhotoPresignedUrlResponse.builder() + .presignedUrl(presignedUrl.toString()) + .s3Key(s3Key) + .build(); + } catch (Exception e) { + log.error("event=presigned_url_generation_failed, user_id={}, file_name={}, error_message={}", + userId, request.getFileName(), e.getMessage(), e); + throw new PhotoException(ErrorMessage.PRESIGNED_URL_GENERATION_FAILED); + } + } + + public PhotoPresignedUrlResponse generateTmpPresignedUrl(final Long userId, final PhotoUploadRequest request) { + try { + String s3Key = createTmpS3Key(request.getFileName()); + URL presignedUrl = createPresignedUrl(s3Key); + log.info("event=tmp_presigned_url_generated, user_id={}, file_name={}, s3_key={}", + userId, request.getFileName(), s3Key); + return PhotoPresignedUrlResponse.builder() + .presignedUrl(presignedUrl.toString()) + .s3Key(s3Key) + .build(); + } catch (Exception e) { + log.error("event=tmp_presigned_url_generation_failed, user_id={}, file_name={}, error_message={}", + userId, request.getFileName(), e.getMessage(), e); + throw new PhotoException(ErrorMessage.PRESIGNED_URL_GENERATION_FAILED); + } + } + + @Transactional + public PhotoResponse registerPhoto(final Long userId, final PhotoRegisterRequest request) { + try { + Store store = storeRepository.findById(request.getStoreId()) + .orElseThrow(() -> new PhotoException(ErrorMessage.STORE_NOT_FOUND)); + + FoodItem foodItem = null; + if (request.getFoodItemId() != null) { + foodItem = foodItemRepository.findById(request.getFoodItemId()) + .orElseThrow(() -> new PhotoException(ErrorMessage.FOODITEM_NOT_FOUND)); + } + Photo photo = Photo.builder() + .s3Key(request.getS3Key()) + .fileName(request.getFileName()) + .store(store) + .foodItem(foodItem) + .fileSize(request.getFileSize()) + .imageWidth(request.getImageWidth()) + .imageHeight(request.getImageHeight()) + .isActive(true) + .build(); + Photo savedPhoto = photoRepository.save(photo); + log.info("event=photo_registered, photo_id={}, s3_key={}, user_id={}", + savedPhoto.getId(), savedPhoto.getS3Key(), userId); + return PhotoResponse.from(savedPhoto, cloudfrontDomain); + } catch (Exception e) { + log.error("event=photo_registration_failed, s3_key={}, user_id={}, error_message={}", + request.getS3Key(), userId, e.getMessage(), e); + throw new PhotoException(ErrorMessage.PHOTO_REGISTRATION_FAILED); + } + } + + public PageResponse getPhotosByStore(final Long storeId, final Pageable pageable) { + try { + Page photoPage = photoRepository.findByStoreIdAndIsActiveTrueWithoutJoin(storeId, pageable); + return PageResponse.from(photoPage.map(photo -> PhotoResponse.from(photo, cloudfrontDomain))); + } catch (Exception e) { + log.error("event=photos_by_store_failed, store_id={}, error_message={}", + storeId, e.getMessage(), e); + throw new PhotoException(ErrorMessage.PHOTO_NOT_FOUND); + } + } + + public PageResponse getPhotosByFoodItem(final Long foodItemId, final Pageable pageable) { + try { + Page photoPage = photoRepository.findByFoodItemIdAndIsActiveTrueWithoutJoin(foodItemId, pageable); + return PageResponse.from(photoPage.map(photo -> PhotoResponse.from(photo, cloudfrontDomain))); + } catch (Exception e) { + log.error("event=photos_by_food_item_failed, food_item_id={}, error_message={}", + foodItemId, e.getMessage(), e); + throw new PhotoException(ErrorMessage.PHOTO_NOT_FOUND); + } + } + + public PhotoResponse getPhoto(final Long photoId) { + try { + Photo photo = findPhotoByIdWithoutJoin(photoId); + return PhotoResponse.from(photo, cloudfrontDomain); + } catch (Exception e) { + log.error("event=photo_get_failed, photo_id={}, error_message={}", + photoId, e.getMessage(), e); + throw new PhotoException(ErrorMessage.PHOTO_NOT_FOUND); + } + } + + @Transactional + public void deletePhoto(final Long userId, final Long photoId) { + try { + Photo photo = findPhotoByIdWithoutJoin(photoId); + if (!userStoreRoleService.canUserManageStore(userId, photo.getStore().getId())) { + throw new PhotoException(ErrorMessage.STORE_ACCESS_DENIED); + } + deleteS3Object(photo.getS3Key()); + photo.softDelete(); + log.info("event=photo_deleted, photo_id={}, user_id={}", photoId, userId); + } catch (Exception e) { + log.error("event=photo_deletion_failed, photo_id={}, user_id={}, error_message={}", + photoId, userId, e.getMessage(), e); + throw new PhotoException(ErrorMessage.PHOTO_DELETE_FAILED); + } + } + + @Transactional + public void setFeaturedPhoto(final Long userId, final PhotoSetFeaturedRequest request) { + try { + Photo photo = findPhotoByIdWithoutJoin(request.getPhotoId()); + if (!userStoreRoleService.canUserManageStore(userId, photo.getStore().getId())) { + throw new PhotoException(ErrorMessage.STORE_ACCESS_DENIED); + } + FoodItem foodItem = foodItemRepository.findById(request.getFoodItemId()) + .orElseThrow(() -> new PhotoException(ErrorMessage.FOODITEM_NOT_FOUND)); + foodItem.setThumbnailUrl(photo.getS3Key()); + foodItemRepository.save(foodItem); + log.info("event=featured_photo_set, photo_id={}, user_id={}", request.getPhotoId(), userId); + } catch (Exception e) { + log.error("event=featured_photo_set_failed, photo_id={}, user_id={}, error_message={}", + request.getPhotoId(), userId, e.getMessage(), e); + throw new PhotoException(ErrorMessage.PHOTO_SET_FEATURED_FAILED); + } + } + + private Photo findPhotoByIdWithoutJoin(final Long photoId) { + return photoRepository.findByIdAndIsActiveTrueWithoutJoin(photoId) + .orElseThrow(() -> new PhotoException(ErrorMessage.PHOTO_NOT_FOUND)); + } + + private void deleteS3Object(final String s3Key) { + try { + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucket) + .key(s3Key) + .build(); + s3Client.deleteObject(deleteObjectRequest); + log.info("event=s3_object_deleted, s3_key={}", s3Key); + } catch (Exception e) { + log.error("event=s3_object_deletion_failed, s3_key={}, error_message={}", + s3Key, e.getMessage(), e); + } + } + + private URL createPresignedUrl(final String s3Key) { + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(s3Key) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) // The URL will be valid for 10 minutes + .putObjectRequest(objectRequest) + .build(); + + return s3Presigner.presignPutObject(presignRequest).url(); + } + + public byte[] processBackgroundRemovalAsBytes(final Long userId, final MultipartFile file, final PhotoBackgroundRemovalRequest request) { + try { + // 포토룸 API로 배경 제거 + byte[] processedImageBytes = photoRoomService.removeBackground(file); + + log.info("event=background_removal_processed, user_id={}, file_name={}", userId, request.getFileName()); + + return processedImageBytes; // 바이너리 데이터 그대로 반환 + + } catch (Exception e) { + log.error("event=background_removal_failed, user_id={}, file_name={}, error_message={}", + userId, request.getFileName(), e.getMessage(), e); + throw new PhotoException(ErrorMessage.PHOTO_BACKGROUND_REMOVAL_FAILED); + } + } + + private String createS3Key(final String fileName) { + Objects.requireNonNull(fileName, "fileName must not be null"); + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex == -1) { + throw new PhotoException(ErrorMessage.PHOTO_INVALID_FORMAT); + } + final String fileExtension = fileName.substring(lastDotIndex); + return "foodPhoto/" + UUID.randomUUID() + fileExtension; + } + + private String createTmpS3Key(final String fileName) { + Objects.requireNonNull(fileName, "fileName must not be null"); + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex == -1) { + throw new PhotoException(ErrorMessage.PHOTO_INVALID_FORMAT); + } + final String fileExtension = fileName.substring(lastDotIndex); + return "tmp/" + UUID.randomUUID() + fileExtension; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/controller/StoreController.java b/src/main/java/com/example/chalpu/store/controller/StoreController.java new file mode 100644 index 0000000..0979b80 --- /dev/null +++ b/src/main/java/com/example/chalpu/store/controller/StoreController.java @@ -0,0 +1,150 @@ +package com.example.chalpu.store.controller; + +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import com.example.chalpu.store.dto.MemberInviteRequest; +import com.example.chalpu.store.dto.MemberResponse; +import com.example.chalpu.store.dto.StoreRequest; +import com.example.chalpu.store.dto.StoreResponse; +import com.example.chalpu.store.service.StoreService; +import com.example.chalpu.store.service.UserStoreRoleService; +import com.example.chalpu.common.exception.StoreException; +import com.example.chalpu.common.exception.ErrorMessage; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/stores") +@RequiredArgsConstructor +@Tag(name = "Store", description = "매장 관리 API") +public class StoreController { + + private final StoreService storeService; + private final UserStoreRoleService userStoreRoleService; + + @GetMapping("/my") + @Operation( + summary = "내 매장 목록 조회", + description = """ + 사용자가 속한 매장 목록을 페이지네이션으로 조회합니다. + + **페이지네이션 파라미터:** + - page: 페이지 번호 (0부터 시작, 기본값: 0) + - size: 페이지 크기 (기본값: 10) + - sort: 정렬 조건 (기본값: createdAt,desc) + + **요청 예시:** + ``` + GET /api/stores/my?page=0&size=10&sort=createdAt,desc + GET /api/stores/my?page=1&size=5&sort=name,asc + ``` + + 위처럼 정렬 조건을 문자열로 줘도 되고 아래처럼 배열로 줘도 됩니다: + ``` + GET /api/stores/my?page=0&size=10&sort=createdAt&sort=desc + ``` + """ + ) + public ResponseEntity>> getMyStores( + @Parameter(description = "페이지네이션 정보") + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + PageResponse stores = userStoreRoleService.getMyStores(userDetails.getId(), pageable); + return ResponseEntity.ok(ApiResponse.success(stores)); + } + + @GetMapping("/{storeId}") + @Operation(summary = "매장 상세 조회", description = "특정 매장의 상세 정보를 조회합니다.") + public ResponseEntity> getStore( + @PathVariable Long storeId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + // 권한 검증: 사용자가 해당 매장에 접근할 수 있는지 확인 + if (!userStoreRoleService.canUserAccessStore(userDetails.getId(), storeId)) { + throw new StoreException(ErrorMessage.STORE_ACCESS_DENIED); + } + + StoreResponse store = storeService.getStore(storeId); + return ResponseEntity.ok(ApiResponse.success(store)); + } + + @PostMapping + @Operation(summary = "매장 생성", description = "새로운 매장을 생성합니다. 생성자는 자동으로 매장의 소유자가 됩니다.") + public ResponseEntity> createStore( + @RequestBody StoreRequest storeRequest, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + // 1. 매장 생성 + StoreResponse store = storeService.createStore(storeRequest); + + // 2. 소유자 역할 생성 + userStoreRoleService.createOwnerRole(userDetails.getId(), store.getStoreId()); + + return ResponseEntity.ok(ApiResponse.success(store)); + } + + @PutMapping("/{storeId}") + @Operation(summary = "매장 정보 수정", description = "매장 정보를 수정합니다. 매장 관리 권한이 필요합니다.") + public ResponseEntity> updateStore( + @PathVariable Long storeId, + @RequestBody StoreRequest storeRequest, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + // 권한 검증: 사용자가 이 매장을 관리할 수 있는지 확인 + if (!userStoreRoleService.canUserManageStore(userDetails.getId(), storeId)) { + throw new StoreException(ErrorMessage.STORE_ACCESS_DENIED); + } + + StoreResponse store = storeService.updateStore(storeId, storeRequest); + return ResponseEntity.ok(ApiResponse.success(store)); + } + + @DeleteMapping("/{storeId}") + @Operation(summary = "매장 삭제", description = "매장을 삭제합니다. 소유자만 삭제할 수 있습니다.") + public ResponseEntity> deleteStore( + @PathVariable Long storeId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + // 권한 검증: 소유자만 매장을 삭제할 수 있음 + if (!userStoreRoleService.canUserManageStore(userDetails.getId(), storeId)) { + throw new StoreException(ErrorMessage.STORE_ACCESS_DENIED); + } + + storeService.deleteStore(storeId); + return ResponseEntity.ok(ApiResponse.success()); + } + + @GetMapping("/{storeId}/members") + @Operation(summary = "매장 멤버 목록 조회", description = "매장에 속한 멤버 목록을 조회합니다.") + public ResponseEntity>> getStoreMembers( + @PathVariable Long storeId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + List members = userStoreRoleService.getStoreMembers(storeId, userDetails.getId()); + return ResponseEntity.ok(ApiResponse.success(members)); + } + + @PostMapping("/{storeId}/members") + @Operation(summary = "매장 멤버 초대", description = "매장에 새로운 멤버를 초대합니다.") + public ResponseEntity> inviteMember( + @PathVariable Long storeId, + @RequestBody MemberInviteRequest memberRequest, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + MemberResponse member = userStoreRoleService.inviteMember(storeId, memberRequest, userDetails.getId()); + return ResponseEntity.ok(ApiResponse.success(member)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/domain/Store.java b/src/main/java/com/example/chalpu/store/domain/Store.java new file mode 100644 index 0000000..5749936 --- /dev/null +++ b/src/main/java/com/example/chalpu/store/domain/Store.java @@ -0,0 +1,66 @@ +package com.example.chalpu.store.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import com.example.chalpu.store.dto.StoreRequest; +import jakarta.persistence.*; +import lombok.*; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Entity +@Table(name = "stores") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = false) +public class Store extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "store_id") + private Long id; + + @Column(length = 100, nullable = false) + private String storeName; + + @Schema(description = "가게 유형 (한식, 양식, 중식)") + @Column(length = 50) + private String businessType; + + @Column(name = "address") + private String address; + + @Schema(description = "가게 번호") + @Column(length = 20) + private String phone; + + @Schema(description = "사업자 등록번호") + @Column(length = 50, unique = true) + private String businessRegistrationNumber; + + @Builder.Default + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + + public static Store createStore(StoreRequest storeRequest){ + return Store.builder() + .storeName(storeRequest.getStoreName()) + .businessType(storeRequest.getBusinessType()) + .address(storeRequest.getAddress()) + .phone(storeRequest.getPhone()) + .businessRegistrationNumber(storeRequest.getBusinessRegistrationNumber()) + .build(); + } + + public void updateStore(StoreRequest storeRequest) { + this.storeName = storeRequest.getStoreName(); + this.businessType = storeRequest.getBusinessType(); + this.address = storeRequest.getAddress(); + this.phone = storeRequest.getPhone(); + this.businessRegistrationNumber = storeRequest.getBusinessRegistrationNumber(); + } + + public void softDelete() { + this.isActive = false; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/domain/StoreRoleType.java b/src/main/java/com/example/chalpu/store/domain/StoreRoleType.java new file mode 100644 index 0000000..32447b0 --- /dev/null +++ b/src/main/java/com/example/chalpu/store/domain/StoreRoleType.java @@ -0,0 +1,42 @@ +package com.example.chalpu.store.domain; + +import lombok.Getter; + +@Getter +public enum StoreRoleType { + OWNER(100), + CO_OWNER(90), + MANAGER(70), + STAFF(30); + + private final int authorityLevel; + + StoreRoleType(int authorityLevel) { + this.authorityLevel = authorityLevel; + } + + public boolean canManageStore() { + return this.authorityLevel >= 70; + } + + public boolean canInviteMembers() { + return this.authorityLevel >= 70; + } + + public boolean canModifyMenu() { + return this.authorityLevel >= 70; + } + + public boolean hasHigherAuthorityThan(StoreRoleType other) { + return this.authorityLevel > other.authorityLevel; + } + + public static StoreRoleType fromString(String roleType) { + for (StoreRoleType type : StoreRoleType.values()) { + if (type.name().equalsIgnoreCase(roleType)) { + return type; + } + } + throw new IllegalArgumentException("Invalid role type: " + roleType); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/domain/UserStoreRole.java b/src/main/java/com/example/chalpu/store/domain/UserStoreRole.java new file mode 100644 index 0000000..b38d091 --- /dev/null +++ b/src/main/java/com/example/chalpu/store/domain/UserStoreRole.java @@ -0,0 +1,101 @@ +package com.example.chalpu.store.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; +import com.example.chalpu.user.domain.User; + + +@NamedEntityGraph( + name = "UserStoreRole.withUserAndStore", + attributeNodes = { + @NamedAttributeNode("user"), + @NamedAttributeNode("store") + } +) +@Entity +@Table(name = "user_store_roles") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class UserStoreRole extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id", nullable = false) + private Store store; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private StoreRoleType roleType; + + @Column(nullable = false) + @Builder.Default + private Boolean isActive = true; + + // 정적 팩토리 메서드 + public static UserStoreRole createOwner(User user, Store store) { + return UserStoreRole.builder() + .user(user) + .store(store) + .roleType(StoreRoleType.OWNER) + .isActive(true) + .build(); + } + + public static UserStoreRole createEmployee(User user, Store store, StoreRoleType roleType) { + if (roleType == StoreRoleType.OWNER) { + throw new IllegalArgumentException("직원 생성 시 OWNER 역할은 사용할 수 없습니다"); + } + return UserStoreRole.builder() + .user(user) + .store(store) + .roleType(roleType) + .isActive(true) + .build(); + } + + // 비즈니스 로직 메서드 + public boolean canManageStore() { + return isActive && roleType.canManageStore(); + } + + public boolean canInviteMembers() { + return isActive && roleType.canInviteMembers(); + } + + public boolean canModifyMenu() { + return isActive && roleType.canModifyMenu(); + } + + public boolean isOwner() { + return roleType == StoreRoleType.OWNER || roleType == StoreRoleType.CO_OWNER; + } + + public boolean hasHigherAuthorityThan(UserStoreRole other) { + return this.roleType.hasHigherAuthorityThan(other.roleType); + } + + public Store getAssociatedStore() { + return this.store; + } + + // 상태 변경 메서드 + public void softDelete() { + this.isActive = false; + } + + public void changeRole(StoreRoleType newRoleType) { + if (!this.isActive) { + throw new IllegalStateException("비활성화된 역할은 변경할 수 없습니다"); + } + this.roleType = newRoleType; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/dto/MemberInviteRequest.java b/src/main/java/com/example/chalpu/store/dto/MemberInviteRequest.java new file mode 100644 index 0000000..fb31700 --- /dev/null +++ b/src/main/java/com/example/chalpu/store/dto/MemberInviteRequest.java @@ -0,0 +1,27 @@ +package com.example.chalpu.store.dto; + +import com.example.chalpu.store.domain.StoreRoleType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "멤버 초대 요청") +public class MemberInviteRequest { + + @Schema(description = "초대할 사용자 ID", example = "1", required = true) + private Long userId; + + @Schema(description = "역할", example = "EMPLOYEE", required = true) + private StoreRoleType roleType; + + @Schema(description = "소유 비율", example = "50") + private BigDecimal ownershipPercentage; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/dto/MemberResponse.java b/src/main/java/com/example/chalpu/store/dto/MemberResponse.java new file mode 100644 index 0000000..639ad35 --- /dev/null +++ b/src/main/java/com/example/chalpu/store/dto/MemberResponse.java @@ -0,0 +1,48 @@ +package com.example.chalpu.store.dto; + +import com.example.chalpu.store.domain.StoreRoleType; +import com.example.chalpu.store.domain.UserStoreRole; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "멤버 응답") +public class MemberResponse { + + @Schema(description = "사용자 ID", example = "1") + private Long userId; + + @Schema(description = "사용자 이름", example = "홍길동") + private String userName; + + @Schema(description = "사용자 이메일", example = "user@example.com") + private String userEmail; + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "역할", example = "EMPLOYEE") + private StoreRoleType roleType; + + @Schema(description = "가입 시간", example = "2024-01-15T09:30:00") + private LocalDateTime joinedAt; + + public static MemberResponse from(UserStoreRole userStoreRole) { + return MemberResponse.builder() + .userId(userStoreRole.getUser() != null ? userStoreRole.getUser().getId() : null) + .userName(userStoreRole.getUser() != null ? userStoreRole.getUser().getName() : null) + .userEmail(userStoreRole.getUser() != null ? userStoreRole.getUser().getEmail() : null) + .storeId(userStoreRole.getStore() != null ? userStoreRole.getStore().getId() : null) + .roleType(userStoreRole.getRoleType()) + .joinedAt(userStoreRole.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/dto/StoreRequest.java b/src/main/java/com/example/chalpu/store/dto/StoreRequest.java new file mode 100644 index 0000000..31a9e8a --- /dev/null +++ b/src/main/java/com/example/chalpu/store/dto/StoreRequest.java @@ -0,0 +1,30 @@ +package com.example.chalpu.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 생성/수정 요청") +public class StoreRequest { + + @Schema(description = "매장명", example = "맛있는 식당", required = true) + private String storeName; + + @Schema(description = "가게 유형", example = "한식") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + private String phone; + + @Schema(description = "사업자 등록번호", example = "123-45-67890") + private String businessRegistrationNumber; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/dto/StoreResponse.java b/src/main/java/com/example/chalpu/store/dto/StoreResponse.java new file mode 100644 index 0000000..d381e6f --- /dev/null +++ b/src/main/java/com/example/chalpu/store/dto/StoreResponse.java @@ -0,0 +1,55 @@ +package com.example.chalpu.store.dto; + +import com.example.chalpu.store.domain.Store; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 응답") +public class StoreResponse { + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "매장명", example = "맛있는 식당") + private String storeName; + + @Schema(description = "가게 유형", example = "한식") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + private String phone; + + @Schema(description = "사업자 등록번호", example = "123-45-67890") + private String businessRegistrationNumber; + + @Schema(description = "생성 시간", example = "2024-01-15T09:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정 시간", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; + + public static StoreResponse from(Store store) { + return StoreResponse.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .businessType(store.getBusinessType()) + .address(store.getAddress()) + .phone(store.getPhone()) + .businessRegistrationNumber(store.getBusinessRegistrationNumber()) + .createdAt(store.getCreatedAt()) + .updatedAt(store.getUpdatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/repository/StoreRepository.java b/src/main/java/com/example/chalpu/store/repository/StoreRepository.java new file mode 100644 index 0000000..b4ff109 --- /dev/null +++ b/src/main/java/com/example/chalpu/store/repository/StoreRepository.java @@ -0,0 +1,20 @@ +package com.example.chalpu.store.repository; + +import com.example.chalpu.store.domain.Store; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface StoreRepository extends JpaRepository { + + Optional findByIdAndIsActiveTrue(Long id); + + List findByIsActiveTrue(); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/repository/UserStoreRoleRepository.java b/src/main/java/com/example/chalpu/store/repository/UserStoreRoleRepository.java new file mode 100644 index 0000000..d1e3249 --- /dev/null +++ b/src/main/java/com/example/chalpu/store/repository/UserStoreRoleRepository.java @@ -0,0 +1,62 @@ +package com.example.chalpu.store.repository; + +import com.example.chalpu.store.domain.UserStoreRole; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserStoreRoleRepository extends JpaRepository { + + @EntityGraph(value = "UserStoreRole.withUserAndStore") + Page findByUserIdAndIsActiveTrue(Long userId, Pageable pageable); + + @EntityGraph(value = "UserStoreRole.withUserAndStore") + List findByUserIdAndIsActiveTrue(Long userId); + + // 특정 유저의 특정 매장에서의 역할 조회 + @EntityGraph(value = "UserStoreRole.withUserAndStore") + Optional findByUserIdAndStoreIdAndIsActiveTrue(Long userId, Long storeId); + + // 특정 매장의 모든 멤버 조회 (활성/비활성 포함) + @EntityGraph(value = "UserStoreRole.withUserAndStore") + List findByStoreId(Long storeId); + + // 특정 매장의 활성화된 모든 멤버 조회 + @EntityGraph(value = "UserStoreRole.withUserAndStore") + List findByStoreIdAndIsActiveTrue(Long storeId); + + // 권한 검증 전용 경량화된 메서드들 (연관 엔티티 조회 없음) + @Query("SELECT usr FROM UserStoreRole usr WHERE usr.user.id = :userId AND usr.isActive = true") + List findByUserIdAndIsActiveTrueWithoutJoin(@Param("userId") Long userId); + + @Query("SELECT usr FROM UserStoreRole usr WHERE usr.user.id = :userId AND usr.store.id = :storeId AND usr.isActive = true") + Optional findByUserIdAndStoreIdAndIsActiveTrueWithoutJoin(@Param("userId") Long userId, @Param("storeId") Long storeId); + + // Store만 fetch join하는 메서드 (User 정보 불필요할 때) + @Query("SELECT usr FROM UserStoreRole usr JOIN FETCH usr.store WHERE usr.user.id = :userId AND usr.isActive = true") + Page findByUserIdAndIsActiveTrueWithStoreOnly(@Param("userId") Long userId, Pageable pageable); + + @Query("SELECT usr FROM UserStoreRole usr JOIN FETCH usr.store WHERE usr.user.id = :userId AND usr.isActive = true") + List findByUserIdAndIsActiveTrueWithStoreOnly(@Param("userId") Long userId); + + /** + * User 삭제 시 연관된 UserStoreRole들 소프트 딜리트 + * @param userId 사용자 ID + */ + @Modifying + @Query("UPDATE UserStoreRole usr SET usr.isActive = false WHERE usr.user.id = :userId") + void softDeleteByUserId(@Param("userId") Long userId); + + @Modifying + @Query("UPDATE UserStoreRole usr SET usr.isActive = true WHERE usr.user.id = :userId") + void activateByUserId(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/service/StoreService.java b/src/main/java/com/example/chalpu/store/service/StoreService.java new file mode 100644 index 0000000..d4f2bfb --- /dev/null +++ b/src/main/java/com/example/chalpu/store/service/StoreService.java @@ -0,0 +1,107 @@ +package com.example.chalpu.store.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.StoreException; +import com.example.chalpu.fooditem.domain.FoodItem; +import com.example.chalpu.fooditem.repository.FoodItemRepository; +import com.example.chalpu.menu.domain.Menu; +import com.example.chalpu.menu.repository.MenuRepository; +import com.example.chalpu.photo.domain.Photo; +import com.example.chalpu.photo.repository.PhotoRepository; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.store.domain.UserStoreRole; +import com.example.chalpu.store.dto.StoreRequest; +import com.example.chalpu.store.dto.StoreResponse; +import com.example.chalpu.store.repository.StoreRepository; +import com.example.chalpu.store.repository.UserStoreRoleRepository; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StoreService { + + private final StoreRepository storeRepository; + private final UserStoreRoleRepository userStoreRoleRepository; + private final PhotoRepository photoRepository; + private final FoodItemRepository foodItemRepository; + private final MenuRepository menuRepository; + + public StoreResponse getStore(Long storeId) { + try { + Store store = storeRepository.findByIdAndIsActiveTrue(storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_NOT_FOUND)); + return StoreResponse.from(store); + } catch (Exception e) { + log.error("event=store_get_failed, store_id={}, error_message={}", storeId, e.getMessage(), e); + throw e; + } + } + + @Transactional + public StoreResponse createStore(StoreRequest storeRequest) { + try { + Store store = Store.createStore(storeRequest); + Store savedStore = storeRepository.save(store); + log.info("event=store_created, store_id={}", savedStore.getId()); + return StoreResponse.from(savedStore); + } catch (Exception e) { + log.error("event=store_creation_failed, store_name={}, error_message={}", + storeRequest.getStoreName(), e.getMessage(), e); + throw new StoreException(ErrorMessage.STORE_CREATE_FAILED); + } + } + + @Transactional + public StoreResponse updateStore(Long storeId, StoreRequest storeRequest) { + try { + Store store = storeRepository.findByIdAndIsActiveTrue(storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_NOT_FOUND)); + + store.updateStore(storeRequest); + log.info("event=store_updated, store_id={}", storeId); + return StoreResponse.from(store); + } catch (Exception e) { + log.error("event=store_update_failed, store_id={}, error_message={}", + storeId, e.getMessage(), e); + throw new StoreException(ErrorMessage.STORE_UPDATE_FAILED); + } + } + + @Transactional + public void deleteStore(Long storeId) { + try { + Store store = storeRepository.findByIdAndIsActiveTrue(storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_NOT_FOUND)); + + List userStoreRoles = userStoreRoleRepository.findByStoreId(storeId); + userStoreRoles.forEach(UserStoreRole::softDelete); + + List photos = photoRepository.findByStoreIdAndIsActiveTrueWithoutJoin(storeId); + photos.forEach(Photo::softDelete); + + List foodItems = foodItemRepository.findByStoreIdAndIsActiveTrueWithoutJoin(storeId, null).getContent(); + foodItems.forEach(FoodItem::softDelete); + + List menus = menuRepository.findByStoreIdAndIsActiveTrueWithoutJoin(storeId, null).getContent(); + menus.forEach(Menu::softDelete); + + log.info("event=all_store_related_entities_soft_deleted, store_id={}, user_roles={}, photos={}, food_items={}, menus={}", + storeId, userStoreRoles.size(), photos.size(), foodItems.size(), menus.size()); + + store.softDelete(); + log.info("event=store_soft_deleted, store_id={}", storeId); + } catch (Exception e) { + log.error("event=store_deletion_failed, store_id={}, error_message={}", + storeId, e.getMessage(), e); + throw new StoreException(ErrorMessage.STORE_DELETE_FAILED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/store/service/UserStoreRoleService.java b/src/main/java/com/example/chalpu/store/service/UserStoreRoleService.java new file mode 100644 index 0000000..9b8c149 --- /dev/null +++ b/src/main/java/com/example/chalpu/store/service/UserStoreRoleService.java @@ -0,0 +1,346 @@ +package com.example.chalpu.store.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.StoreException; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.store.domain.StoreRoleType; +import com.example.chalpu.store.domain.UserStoreRole; +import com.example.chalpu.store.dto.MemberInviteRequest; +import com.example.chalpu.store.dto.MemberResponse; +import com.example.chalpu.store.dto.StoreResponse; +import com.example.chalpu.store.repository.StoreRepository; +import com.example.chalpu.store.repository.UserStoreRoleRepository; +import com.example.chalpu.user.domain.User; +import com.example.chalpu.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserStoreRoleService { + + private final UserStoreRoleRepository userStoreRoleRepository; + private final StoreRepository storeRepository; + private final UserRepository userRepository; + + /** + * 사용자가 속한 매장 목록 조회 (페이지네이션) + */ + public PageResponse getMyStores(Long userId, Pageable pageable) { + Page userStoreRolePage = userStoreRoleRepository.findByUserIdAndIsActiveTrueWithStoreOnly(userId, pageable); + Page storeResponsePage = userStoreRolePage.map(usr -> StoreResponse.from(usr.getStore())); + log.info("event=my_stores_retrieved, user_id={}, total_elements={}, total_pages={}, current_page={}", + userId, userStoreRolePage.getTotalElements(), userStoreRolePage.getTotalPages(), userStoreRolePage.getNumber()); + return PageResponse.from(storeResponsePage); + } + + /** + * 사용자가 속한 매장 목록 조회 (전체) + */ + public List getMyStores(Long userId) { + List userStoreRoles = userStoreRoleRepository.findByUserIdAndIsActiveTrueWithStoreOnly(userId); + log.info("userStoreRoles: {}", userStoreRoles); + return userStoreRoles.stream() + .map(usr -> StoreResponse.from(usr.getStore())) + .toList(); + } + + /** + * 사용자가 소유한 매장 목록 조회 + */ + public List getOwnedStores(Long userId) { + List userStoreRoles = userStoreRoleRepository.findByUserIdAndIsActiveTrueWithStoreOnly(userId); + log.info("userStoreRoles: {}", userStoreRoles); + return userStoreRoles.stream() + .filter(UserStoreRole::getIsActive) + .filter(UserStoreRole::isOwner) + .map(role -> StoreResponse.from(role.getStore())) + .toList(); + } + + /** + * 사용자가 관리할 수 있는 매장 목록 조회 + */ + public List getManageableStores(Long userId) { + List userStoreRoles = userStoreRoleRepository.findByUserIdAndIsActiveTrueWithStoreOnly(userId); + log.info("userStoreRoles: {}", userStoreRoles); + return userStoreRoles.stream() + .filter(UserStoreRole::getIsActive) + .filter(UserStoreRole::canManageStore) + .map(role -> StoreResponse.from(role.getStore())) + .toList(); + } + + /** + * 특정 매장의 멤버 목록 조회 + */ + public List getStoreMembers(Long storeId, Long requestUserId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_NOT_FOUND)); + + // 권한 검증: 해당 매장에 속한 사용자만 멤버 목록 조회 가능 + List requestUserRoles = userStoreRoleRepository.findByUserIdAndIsActiveTrue(requestUserId); + if (!canUserAccessStore(requestUserRoles, store)) { + log.error("canUserAccessStore: {}, 해당 매장에 속하지 않습니다.", canUserAccessStore(requestUserRoles, store)); + throw new StoreException(ErrorMessage.STORE_ACCESS_DENIED); + } + + // 매장의 모든 멤버 조회 (활성 멤버만) + List storeMembers = userStoreRoleRepository.findByStoreId(storeId) + .stream() + .filter(UserStoreRole::getIsActive) + .toList(); + + log.info("storeMembers: {}", storeMembers); + return storeMembers.stream() + .map(MemberResponse::from) + .toList(); + } + + /** + * 매장 소유자 역할 생성 (사용자 ID와 매장 ID로) + */ + @Transactional + public void createOwnerRole(Long userId, Long storeId) { + try { + User user = userRepository.findById(userId) + .orElseThrow(() -> new StoreException(ErrorMessage.USER_NOT_FOUND)); + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_NOT_FOUND)); + + createOwnerRole(user, store); + + log.info("event=owner_role_created, user_id={}, store_id={}", userId, storeId); + } catch (Exception e) { + log.error("event=owner_role_creation_failed, user_id={}, store_id={}, error_message={}", + userId, storeId, e.getMessage(), e); + throw e; + } + } + + /** + * 매장 소유자 역할 생성 (매장 생성 시 사용) + */ + public void createOwnerRole(User user, Store store) { + UserStoreRole ownerRole = UserStoreRole.createOwner(user, store); + userStoreRoleRepository.save(ownerRole); + } + + /** + * 매장에 멤버 초대 + */ + @Transactional + public MemberResponse inviteMember(Long storeId, MemberInviteRequest memberRequest, Long inviterUserId) { + try { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_NOT_FOUND)); + User inviteUser = userRepository.findById(memberRequest.getUserId()) + .orElseThrow(() -> new StoreException(ErrorMessage.USER_NOT_FOUND)); + + // 권한 검증: 멤버 초대 권한이 있는지 확인 + List inviterRoles = userStoreRoleRepository.findByUserIdAndIsActiveTrue(inviterUserId); + if (!canInviteMembers(inviterRoles, store)) { + throw new StoreException(ErrorMessage.STORE_ACCESS_DENIED); + } + + // 이미 매장 구성원인지 확인 + userStoreRoleRepository.findByUserIdAndStoreIdAndIsActiveTrue(memberRequest.getUserId(), storeId) + .ifPresent(existingRole -> { + throw new StoreException(ErrorMessage.STORE_MEMBER_ALREADY_EXISTS); + }); + + // 직원 역할 생성 + UserStoreRole newUserRole = UserStoreRole.createEmployee(inviteUser, store, memberRequest.getRoleType()); + UserStoreRole savedUserStoreRole = userStoreRoleRepository.save(newUserRole); + + log.info("event=member_invited, store_id={}, invited_user_id={}, inviter_user_id={}", + storeId, memberRequest.getUserId(), inviterUserId); + + return MemberResponse.from(savedUserStoreRole); + } catch (Exception e) { + log.error("event=member_invitation_failed, store_id={}, invited_user_id={}, inviter_user_id={}, error_message={}", + storeId, memberRequest.getUserId(), inviterUserId, e.getMessage(), e); + throw e; + } + } + + /** + * 멤버 역할 변경 + */ + @Transactional + public MemberResponse changeRole(Long storeId, Long targetUserId, StoreRoleType newRoleType, Long requestUserId) { + try { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_NOT_FOUND)); + + // 요청자와 대상자의 역할 조회 + UserStoreRole requestUserRole = userStoreRoleRepository.findByUserIdAndStoreIdAndIsActiveTrue(requestUserId, storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_ACCESS_DENIED)); + + UserStoreRole targetRole = userStoreRoleRepository.findByUserIdAndStoreIdAndIsActiveTrue(targetUserId, storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_MEMBER_NOT_FOUND)); + + // 권한 검증 + validateRoleChange(requestUserRole, targetRole, newRoleType); + + // 역할 변경 + targetRole.changeRole(newRoleType); + userStoreRoleRepository.save(targetRole); + + log.info("event=member_role_changed, store_id={}, target_user_id={}, new_role={}, request_user_id={}", + storeId, targetUserId, newRoleType, requestUserId); + + return MemberResponse.from(targetRole); + } catch (Exception e) { + log.error("event=member_role_change_failed, store_id={}, target_user_id={}, request_user_id={}, error_message={}", + storeId, targetUserId, requestUserId, e.getMessage(), e); + throw e; + } + } + + /** + * 멤버 제거 (비활성화) + */ + @Transactional + public void removeMember(Long storeId, Long targetUserId, Long requestUserId) { + try { + // 요청자의 권한 확인 + if (!canUserManageStore(requestUserId, storeId)) { + throw new StoreException(ErrorMessage.STORE_ACCESS_DENIED); + } + + // 대상자 역할 조회 + UserStoreRole targetRole = userStoreRoleRepository.findByUserIdAndStoreIdAndIsActiveTrue(targetUserId, storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_MEMBER_NOT_FOUND)); + + // 본인은 제거할 수 없음 + if (targetUserId.equals(requestUserId)) { + throw new StoreException(ErrorMessage.INVALID_REQUEST); + } + + targetRole.softDelete(); + userStoreRoleRepository.save(targetRole); + + log.info("event=member_removed, store_id={}, target_user_id={}, request_user_id={}", + storeId, targetUserId, requestUserId); + } catch (Exception e) { + log.error("event=member_removal_failed, store_id={}, target_user_id={}, request_user_id={}, error_message={}", + storeId, targetUserId, requestUserId, e.getMessage(), e); + throw e; + } + } + + /** + * 매장 탈퇴 (본인) + */ + @Transactional + public void leaveStore(Long storeId, Long userId) { + try { + UserStoreRole userRole = userStoreRoleRepository.findByUserIdAndStoreIdAndIsActiveTrue(userId, storeId) + .orElseThrow(() -> new StoreException(ErrorMessage.STORE_MEMBER_NOT_FOUND)); + + // 소유자는 탈퇴할 수 없음 + if (userRole.isOwner()) { + throw new StoreException(ErrorMessage.STORE_OWNER_CANNOT_LEAVE); + } + + userRole.softDelete(); + userStoreRoleRepository.save(userRole); + + log.info("event=member_left_store, store_id={}, user_id={}", storeId, userId); + } catch (Exception e) { + log.error("event=store_leave_failed, store_id={}, user_id={}, error_message={}", + storeId, userId, e.getMessage(), e); + throw e; + } + } + + /** + * 특정 매장에서 사용자의 권한 확인 + */ + public boolean canUserAccessStore(Long userId, Long storeId) { + // Store 엔티티 조회 없이 storeId만으로 권한 확인 + List userRoles = userStoreRoleRepository.findByUserIdAndIsActiveTrueWithoutJoin(userId); + return userRoles.stream() + .filter(UserStoreRole::getIsActive) + .anyMatch(role -> role.getStore().getId().equals(storeId)); + } + + /** + * 특정 매장에서 사용자의 관리 권한 확인 + */ + public boolean canUserManageStore(Long userId, Long storeId) { + // Store 엔티티 조회 없이 storeId만으로 권한 확인 + Optional userRole = userStoreRoleRepository.findByUserIdAndStoreIdAndIsActiveTrueWithoutJoin(userId, storeId); + return userRole + .filter(UserStoreRole::getIsActive) + .map(UserStoreRole::canManageStore) + .orElse(false); + } + + // === 내부 유틸리티 메서드들 === + + /** + * 사용자가 특정 매장에 접근할 수 있는지 확인 + */ + private boolean canUserAccessStore(List userRoles, Store store) { + return userRoles.stream() + .filter(UserStoreRole::getIsActive) + .anyMatch(role -> role.getStore().equals(store)); + } + + /** + * 사용자가 특정 매장을 관리할 수 있는지 확인 + */ + private boolean canManageStore(List userRoles, Store store) { + return getUserRoleInStore(userRoles, store) + .map(UserStoreRole::canManageStore) + .orElse(false); + } + + /** + * 사용자가 특정 매장에 멤버를 초대할 수 있는지 확인 + */ + private boolean canInviteMembers(List userRoles, Store store) { + return getUserRoleInStore(userRoles, store) + .map(UserStoreRole::canInviteMembers) + .orElse(false); + } + + /** + * 역할 변경 시 권한 검증 + */ + private void validateRoleChange(UserStoreRole currentUserRole, UserStoreRole targetRole, StoreRoleType newRoleType) { + if (!currentUserRole.canInviteMembers()) { + throw new StoreException(ErrorMessage.STORE_ACCESS_DENIED); + } + + if (!currentUserRole.hasHigherAuthorityThan(targetRole)) { + throw new StoreException(ErrorMessage.STORE_ACCESS_DENIED); + } + + if (currentUserRole.getRoleType().getAuthorityLevel() <= newRoleType.getAuthorityLevel()) { + throw new StoreException(ErrorMessage.STORE_ACCESS_DENIED); + } + } + + /** + * 사용자의 특정 매장에서의 역할 조회 + */ + private Optional getUserRoleInStore(List userRoles, Store store) { + return userRoles.stream() + .filter(UserStoreRole::getIsActive) + .filter(role -> role.getStore().equals(store)) + .findFirst(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/tag/controller/GuideTagController.java b/src/main/java/com/example/chalpu/tag/controller/GuideTagController.java new file mode 100644 index 0000000..62f8260 --- /dev/null +++ b/src/main/java/com/example/chalpu/tag/controller/GuideTagController.java @@ -0,0 +1,43 @@ +package com.example.chalpu.tag.controller; + +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.tag.dto.TagRequest; +import com.example.chalpu.tag.dto.TagResponse; +import com.example.chalpu.tag.service.GuideTagService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "가이드-태그 관리 API", description = "특정 가이드에 대한 태그를 추가/삭제/조회하는 API") +@RestController +@RequestMapping("api/guides/{guideId}/tags") +@RequiredArgsConstructor +public class GuideTagController { + + private final GuideTagService guideTagService; + + @Operation(summary = "가이드에 태그 추가", description = "특정 가이드에 새로운 태그를 연결합니다. 태그가 DB에 없으면 새로 생성됩니다.") + @PostMapping + public ResponseEntity> addTagToGuide(@PathVariable Long guideId, @RequestBody TagRequest request) { + TagResponse response = guideTagService.addTagToGuide(guideId, request.getTagName()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation(summary = "가이드의 태그 삭제", description = "특정 가이드와 특정 태그의 연결을 끊습니다.") + @DeleteMapping("/{tagId}") + public ResponseEntity> removeTagFromGuide(@PathVariable Long guideId, @PathVariable Long tagId) { + guideTagService.removeTagFromGuide(guideId, tagId); + return ResponseEntity.ok(ApiResponse.success()); + } + + @Operation(summary = "가이드의 모든 태그 조회", description = "특정 가이드에 연결된 모든 태그 목록을 조회합니다.") + @GetMapping + public ResponseEntity>> getTagsForGuide(@PathVariable Long guideId) { + List response = guideTagService.getTagsForGuide(guideId); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/src/main/java/com/example/chalpu/tag/domain/GuideTag.java b/src/main/java/com/example/chalpu/tag/domain/GuideTag.java new file mode 100644 index 0000000..fd577f8 --- /dev/null +++ b/src/main/java/com/example/chalpu/tag/domain/GuideTag.java @@ -0,0 +1,67 @@ +package com.example.chalpu.tag.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import com.example.chalpu.guide.domain.Guide; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NamedEntityGraph( + name = "GuideTag.withGuideAndTag", + attributeNodes = { + @NamedAttributeNode(value = "guide", subgraph = "guide-subgraph"), + @NamedAttributeNode("tag") + }, + subgraphs = { + @NamedSubgraph( + name = "guide-subgraph", + attributeNodes = { + @NamedAttributeNode(value = "subCategory", subgraph = "subcategory-subgraph") + } + ), + @NamedSubgraph( + name = "subcategory-subgraph", + attributeNodes = { + @NamedAttributeNode("category") + } + ) + } +) +@Entity +@Getter +@Table(name = "guide_tags") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GuideTag extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "guide_id", nullable = false) + private Guide guide; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + @Column(nullable = false) + private Boolean isActive; + + @Builder + public GuideTag(Guide guide, Tag tag) { + this.guide = guide; + this.tag = tag; + this.isActive = true; + } + + public void softDelete() { + this.isActive = false; + } + + public void activate() { + this.isActive = true; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/tag/domain/Tag.java b/src/main/java/com/example/chalpu/tag/domain/Tag.java new file mode 100644 index 0000000..39606fe --- /dev/null +++ b/src/main/java/com/example/chalpu/tag/domain/Tag.java @@ -0,0 +1,32 @@ +package com.example.chalpu.tag.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Table(name = "tags") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Tag extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @Column(nullable = false) + private Boolean isActive; + + @Builder + public Tag(String name) { + this.name = name; + this.isActive = true; + } + + public void softDelete() { + this.isActive = false; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/tag/dto/TagRequest.java b/src/main/java/com/example/chalpu/tag/dto/TagRequest.java new file mode 100644 index 0000000..37370b0 --- /dev/null +++ b/src/main/java/com/example/chalpu/tag/dto/TagRequest.java @@ -0,0 +1,8 @@ +package com.example.chalpu.tag.dto; + +import lombok.Getter; + +@Getter +public class TagRequest { + private String tagName; +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/tag/dto/TagResponse.java b/src/main/java/com/example/chalpu/tag/dto/TagResponse.java new file mode 100644 index 0000000..bd82ab8 --- /dev/null +++ b/src/main/java/com/example/chalpu/tag/dto/TagResponse.java @@ -0,0 +1,19 @@ +package com.example.chalpu.tag.dto; + +import com.example.chalpu.tag.domain.Tag; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TagResponse { + private final Long tagId; + private final String tagName; + + public static TagResponse from(Tag tag) { + return TagResponse.builder() + .tagId(tag.getId()) + .tagName(tag.getName()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/tag/repository/GuideTagRepository.java b/src/main/java/com/example/chalpu/tag/repository/GuideTagRepository.java new file mode 100644 index 0000000..0721edf --- /dev/null +++ b/src/main/java/com/example/chalpu/tag/repository/GuideTagRepository.java @@ -0,0 +1,32 @@ +package com.example.chalpu.tag.repository; + +import com.example.chalpu.guide.domain.Guide; +import com.example.chalpu.tag.domain.GuideTag; +import com.example.chalpu.tag.domain.Tag; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface GuideTagRepository extends JpaRepository { + @EntityGraph("GuideTag.withGuideAndTag") + List findByGuideAndIsActiveTrue(Guide guide); + @EntityGraph("GuideTag.withGuideAndTag") + List findByGuideInAndIsActiveTrue(List guides); + @EntityGraph("GuideTag.withGuideAndTag") + Optional findByGuideAndTagAndIsActiveTrue(Guide guide, Tag tag); + @EntityGraph("GuideTag.withGuideAndTag") + Optional findByGuideIdAndTagIdAndIsActiveTrue(Long guideId, Long tagId); + @EntityGraph("GuideTag.withGuideAndTag") + List findByGuideIdAndIsActiveTrue(Long guideId); + + // 최적화된 메서드들 - 연관 엔티티 조회 없이 ID만으로 처리 + @Query("SELECT gt FROM GuideTag gt WHERE gt.guide.id = :guideId AND gt.tag.id = :tagId AND gt.isActive = true") + Optional findByGuideIdAndTagIdAndIsActiveTrueWithoutJoin(@Param("guideId") Long guideId, @Param("tagId") Long tagId); + + @Query("SELECT CASE WHEN COUNT(gt) > 0 THEN true ELSE false END FROM GuideTag gt WHERE gt.guide.id = :guideId AND gt.tag.id = :tagId AND gt.isActive = true") + boolean existsByGuideIdAndTagIdAndIsActiveTrue(@Param("guideId") Long guideId, @Param("tagId") Long tagId); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/tag/repository/TagRepository.java b/src/main/java/com/example/chalpu/tag/repository/TagRepository.java new file mode 100644 index 0000000..a07e0b0 --- /dev/null +++ b/src/main/java/com/example/chalpu/tag/repository/TagRepository.java @@ -0,0 +1,11 @@ +package com.example.chalpu.tag.repository; + +import com.example.chalpu.tag.domain.Tag; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface TagRepository extends JpaRepository { + Optional findByNameAndIsActiveTrue(String name); + + Optional findByIdAndIsActiveTrue(Long id); +} \ No newline at end of file diff --git a/src/main/java/com/example/chalpu/tag/service/GuideTagService.java b/src/main/java/com/example/chalpu/tag/service/GuideTagService.java new file mode 100644 index 0000000..5c377ae --- /dev/null +++ b/src/main/java/com/example/chalpu/tag/service/GuideTagService.java @@ -0,0 +1,77 @@ +package com.example.chalpu.tag.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.GuideTagException; +import com.example.chalpu.common.exception.NoticeException; +import com.example.chalpu.guide.domain.Guide; +import com.example.chalpu.guide.repository.GuideRepository; +import com.example.chalpu.tag.domain.GuideTag; +import com.example.chalpu.tag.domain.Tag; +import com.example.chalpu.tag.dto.TagResponse; +import com.example.chalpu.tag.repository.GuideTagRepository; +import com.example.chalpu.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GuideTagService { + + private final GuideRepository guideRepository; + private final TagRepository tagRepository; + private final GuideTagRepository guideTagRepository; + + @Transactional + public TagResponse addTagToGuide(Long guideId, String tagName) { + // Guide 존재 여부만 확인 (엔티티 조회 없이) + if (!guideRepository.existsById(guideId)) { + throw new NoticeException(ErrorMessage.GUIDE_NOT_FOUND); + } + + // Tag 조회 또는 생성 + Tag tag = tagRepository.findByNameAndIsActiveTrue(tagName) + .orElseGet(() -> tagRepository.save(Tag.builder().name(tagName).build())); + + // 기존 GuideTag 존재 여부 확인 (경량화된 쿼리 사용) + Optional existingGuideTag = guideTagRepository.findByGuideIdAndTagIdAndIsActiveTrueWithoutJoin(guideId, tag.getId()); + + if (existingGuideTag.isPresent()) { + GuideTag guideTag = existingGuideTag.get(); + if (guideTag.getIsActive()) { + throw new GuideTagException(ErrorMessage.TAG_ALREADY_EXISTS); + } else { + guideTag.activate(); + } + } else { + // Guide 엔티티가 필요한 경우에만 조회 + Guide guide = guideRepository.findById(guideId) + .orElseThrow(() -> new NoticeException(ErrorMessage.GUIDE_NOT_FOUND)); + guideTagRepository.save(GuideTag.builder().guide(guide).tag(tag).build()); + } + + return TagResponse.from(tag); + } + + @Transactional + public void removeTagFromGuide(Long guideId, Long tagId) { + GuideTag guideTag = guideTagRepository.findByGuideIdAndTagIdAndIsActiveTrue(guideId, tagId) + .orElseThrow(() -> new GuideTagException(ErrorMessage.GUIDE_TAG_NOT_FOUND)); + guideTag.softDelete(); + } + + public List getTagsForGuide(Long guideId) { + if (!guideRepository.existsById(guideId)) { + throw new NoticeException(ErrorMessage.GUIDE_NOT_FOUND); + } + List guideTags = guideTagRepository.findByGuideIdAndIsActiveTrue(guideId); + return guideTags.stream() + .map(guideTag -> TagResponse.from(guideTag.getTag())) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/chalpu/user/controller/UserController.java b/src/main/java/com/example/chalpu/user/controller/UserController.java new file mode 100644 index 0000000..45e673f --- /dev/null +++ b/src/main/java/com/example/chalpu/user/controller/UserController.java @@ -0,0 +1,59 @@ +package com.example.chalpu.user.controller; + +import com.example.chalpu.common.exception.AuthException; +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.response.ApiResponse; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import com.example.chalpu.user.domain.User; +import com.example.chalpu.user.dto.UserDto; +import com.example.chalpu.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.DeleteMapping; + +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor +@Tag(name = "User", description = "사용자 정보 관련 API") +public class UserController { + + private final UserService userService; + + @Operation( + summary = "현재 사용자 정보 조회", + description = "JWT 인증을 통해 현재 로그인한 사용자의 상세 정보를 조회합니다.", + security = { @SecurityRequirement(name = "bearerAuth") } + ) + @GetMapping("/me") + public ResponseEntity> getCurrentUser( + @AuthenticationPrincipal UserDetailsImpl currentUser) { + + if (currentUser == null) { + throw new AuthException(ErrorMessage.AUTH_UNAUTHORIZED); + } + + // 서비스를 통해 사용자 정보 조회 + User user = userService.getUserById(currentUser.getId()); + UserDto userDto = new UserDto(user); + + return ResponseEntity.ok(ApiResponse.success("사용자 정보 조회가 완료되었습니다.", userDto)); + } + + @Operation( + summary = "사용자 정보 삭제", + description = "사용자 정보를 삭제합니다.", + security = { @SecurityRequirement(name = "bearerAuth") } + ) + @DeleteMapping("/me") + public ResponseEntity> deleteUser(@AuthenticationPrincipal UserDetailsImpl currentUser) { + userService.softDelete(currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success()); + } +} diff --git a/src/main/java/com/example/chalpu/user/domain/Role.java b/src/main/java/com/example/chalpu/user/domain/Role.java new file mode 100644 index 0000000..c790ba9 --- /dev/null +++ b/src/main/java/com/example/chalpu/user/domain/Role.java @@ -0,0 +1,6 @@ +package com.example.chalpu.user.domain; + +public enum Role { + ROLE_USER, + ROLE_ADMIN +} diff --git a/src/main/java/com/example/chalpu/user/domain/User.java b/src/main/java/com/example/chalpu/user/domain/User.java new file mode 100644 index 0000000..84d972a --- /dev/null +++ b/src/main/java/com/example/chalpu/user/domain/User.java @@ -0,0 +1,71 @@ +package com.example.chalpu.user.domain; + +import com.example.chalpu.common.entity.BaseTimeEntity; +import com.example.chalpu.oauth.model.AuthProvider; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = false) +public class User extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(length = 100, unique = true, nullable = false) + private String email; + + @Column(length = 100) + private String name; + + @Column(length = 100) + private String providerUserId; + + @Column(length = 20, unique = true) + private String phone; + + @Column(nullable = false) + @Builder.Default + private Boolean isActive = true; + + @Column(name = "social_id") + private String socialId; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private AuthProvider provider; + + private String uuid; + + @Column(name = "last_login") + private String lastLogin; + + @Enumerated(EnumType.STRING) + private Role role; + + private String picture; + + public void updateOAuth2Info(String name, String picture) { + this.name = name; + this.picture = picture; + } + + public void softDelete() { + this.isActive = false; + super.setDeletedAt(LocalDateTime.now()); + } + + public void activate() { + this.isActive = true; + super.setDeletedAt(null); + } +} diff --git a/src/main/java/com/example/chalpu/user/dto/UserDto.java b/src/main/java/com/example/chalpu/user/dto/UserDto.java new file mode 100644 index 0000000..c7afd1c --- /dev/null +++ b/src/main/java/com/example/chalpu/user/dto/UserDto.java @@ -0,0 +1,21 @@ +package com.example.chalpu.user.dto; + +import com.example.chalpu.user.domain.User; +import lombok.Getter; + +@Getter +public class UserDto { + private Long id; + private String email; + private String name; + private String profileImageUrl; + private String provider; + + public UserDto(User user) { + this.id = user.getId(); + this.email = user.getEmail(); + this.name = user.getName(); + this.profileImageUrl = user.getPicture(); + this.provider = user.getProvider().name(); + } +} diff --git a/src/main/java/com/example/chalpu/user/repository/UserRepository.java b/src/main/java/com/example/chalpu/user/repository/UserRepository.java new file mode 100644 index 0000000..0a93e32 --- /dev/null +++ b/src/main/java/com/example/chalpu/user/repository/UserRepository.java @@ -0,0 +1,56 @@ +package com.example.chalpu.user.repository; + +import com.example.chalpu.oauth.model.AuthProvider; +import com.example.chalpu.user.domain.User; +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 java.util.List; +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + // --- 기본 조회: 활성 사용자(deletedAt IS NULL)만 조회 --- + + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByEmailAndDeletedAtIsNull(String email); + + List findAllByDeletedAtIsNull(); + + boolean existsByEmailAndDeletedAtIsNull(String email); + + Optional findByProviderAndSocialIdAndDeletedAtIsNull(AuthProvider provider, String socialId); + + // --- 상태 무시 조회: deletedAt 값과 상관없이 모든 사용자를 조회 --- + + @Query("SELECT u FROM User u WHERE u.id = :id") + Optional findByIdWithDeleted(@Param("id") Long id); + + @Query("SELECT u FROM User u WHERE u.email = :email") + Optional findByEmailWithDeleted(@Param("email") String email); + + // --- 기존 코드와의 호환성을 위한 유지 (isActive 필드 사용) --- + // 만약 isActive 필드를 제거한다면 이 메서드들도 함께 제거/수정해야 합니다. + + @Query("SELECT u FROM User u WHERE u.id = :id AND u.isActive = true") + Optional findActiveById(@Param("id") Long id); + + @Query("SELECT u FROM User u WHERE u.email = :email AND u.isActive = true") + Optional findActiveByEmail(@Param("email") String email); + + @Query("SELECT u FROM User u WHERE u.provider = :provider AND u.socialId = :socialId AND u.isActive = true") + Optional findActiveByProviderAndSocialId(@Param("provider") AuthProvider provider, @Param("socialId") String socialId); + + @Query("SELECT COUNT(u) > 0 FROM User u WHERE u.email = :email AND u.isActive = true") + boolean existsActiveByEmail(@Param("email") String email); + + @Query("SELECT COUNT(u) > 0 FROM User u WHERE u.email = :email AND u.provider != :provider AND u.isActive = true") + boolean existsActiveByEmailAndProviderNot(@Param("email") String email, @Param("provider") AuthProvider provider); + + @Query("SELECT u FROM User u WHERE u.isActive = true") + List findAllActive(); +} diff --git a/src/main/java/com/example/chalpu/user/service/UserService.java b/src/main/java/com/example/chalpu/user/service/UserService.java new file mode 100644 index 0000000..f06e7c2 --- /dev/null +++ b/src/main/java/com/example/chalpu/user/service/UserService.java @@ -0,0 +1,94 @@ +package com.example.chalpu.user.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.UserException; +import com.example.chalpu.oauth.dto.UserInfoDTO; +import com.example.chalpu.oauth.security.jwt.UserDetailsImpl; +import com.example.chalpu.user.domain.User; +import com.example.chalpu.user.repository.UserRepository; +import com.example.chalpu.photo.repository.PhotoRepository; +import com.example.chalpu.store.repository.UserStoreRoleRepository; +import com.example.chalpu.fcm.repository.UserFCMTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PhotoRepository photoRepository; + private final UserStoreRoleRepository userStoreRoleRepository; + private final UserFCMTokenRepository userFCMTokenRepository; + + public UserInfoDTO getCurrentUser(UserDetailsImpl currentUser) { + User user = userRepository.findByIdAndDeletedAtIsNull(currentUser.getId()) + .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); + + return UserInfoDTO.fromEntity(user); + } + + public User getUserById(Long id) { + return userRepository.findByIdAndDeletedAtIsNull(id) + .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); + } + + public User getUserByEmail(String email) { + return userRepository.findByEmailAndDeletedAtIsNull(email) + .orElseThrow(() -> new UserException(ErrorMessage.USER_INVALID_CREDENTIALS)); + } + + /** + * 이메일 중복 체크 + * + * @param email 확인할 이메일 + * @return 중복되지 않은 경우 true, 중복된 경우 false + */ + public boolean isEmailAvailable(String email) { + return !userRepository.existsByEmailAndDeletedAtIsNull(email); + } + + @Transactional + public void softDelete(Long userId) { + User user = userRepository.findByIdWithDeleted(userId) + .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); + + // 이미 삭제된 경우 추가 작업 없이 종료 + if (user.getDeletedAt() != null) { + log.warn("event=AlreadyDeletedUserAttempt, userId={}", userId); + return; + } + + // 1. 연관된 Photo들 소프트 딜리트 + photoRepository.softDeleteByUserId(userId); + + // 2. 연관된 UserStoreRole들 소프트 딜리트 + userStoreRoleRepository.softDeleteByUserId(userId); + + // 3. 연관된 UserFCMToken들 소프트 딜리트 + userFCMTokenRepository.softDeleteByUserId(userId); + + log.info("event=UserSoftDeleted, userId={}", userId); + + // 4. User 자체 소프트 딜리트 + user.softDelete(); + userRepository.save(user); // 변경된 상태를 DB에 반영 + } + + @Transactional + public void activateUser(Long userId) { + User user = userRepository.findByIdWithDeleted(userId) + .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); + + user.activate(); + // 연관된 데이터 활성화 로직은 필요에 따라 추가 + photoRepository.activateByUserId(userId); + userStoreRoleRepository.activateByUserId(userId); + userFCMTokenRepository.activateByUserId(userId); + userRepository.save(user); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..4d83404 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,109 @@ +spring: + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + config: + activate: + on-profile: dev + + datasource: + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update # 프로덕션에서는 스키마를 자동으로 변경하지 않도록 validate 사용 + show-sql: false + properties: + hibernate: + format_sql: false + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} # 웹용 클라이언트 ID + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "http://43.201.106.31/api/login/oauth2/code/{registrationId}" + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + scope: + - profile_nickname + - account_email + naver: + client-id: ${NAVER_CLIENT_ID} # 웹용 클라이언트 ID + client-secret: ${NAVER_CLIENT_SECRET} + redirect-uri: "http://43.201.106.31/api/login/oauth2/code/{registrationId}" + authorization-grant-type: authorization_code + scope: + - name + - email + google: + client-id: ${GOOGLE_CLIENT_IDS} + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + application: + name: chalpu + +jwt: + secret: ${JWT_SECRET} + expiration: 3600000 + refresh-token-expiration: 604800000 + +server: + port: ${SERVER_PORT:8080} + forward-headers-strategy: native + tomcat: + use-relative-redirects: true + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + show-details: when-authorized + prometheus: + enabled: true + prometheus: + metrics: + export: + enabled: true + +# OAuth2 Redirect URLs +oauth2: + redirect: + success-url: ${OAUTH2_REDIRECT_SUCCESS_URL} + failure-url: ${OAUTH2_REDIRECT_FAILURE_URL} + +fcm: + service-account-key-json: ${FCM_SERVICE_ACCOUNT_KEY_JSON} + + +cloud: + aws: + s3: + bucket: chalpu-photo-bucket + cloudfront: + domain: https://cdn.chalpu.com + region: + static: ap-northeast-2 + stack: + auto: false + +photoroom: + api: + url: https://sdk.photoroom.com/v1/segment + key: ${PHOTOROOM_API_KEY} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..93939f6 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,109 @@ +spring: + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + config: + activate: + on-profile: prod + + datasource: + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME_PROD}?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate # 프로덕션에서는 스키마를 자동으로 변경하지 않도록 validate 사용 + show-sql: false + properties: + hibernate: + format_sql: false + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} # 웹용 클라이언트 ID + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "https://prod.chalpu.com/api/login/oauth2/code/{registrationId}" + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + scope: + - profile_nickname + - account_email + naver: + client-id: ${NAVER_CLIENT_ID} # 웹용 클라이언트 ID + client-secret: ${NAVER_CLIENT_SECRET} + redirect-uri: "https://prod.chalpu.com/api/login/oauth2/code/{registrationId}" + authorization-grant-type: authorization_code + scope: + - name + - email + google: + client-id: ${GOOGLE_CLIENT_IDS} + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + application: + name: chalpu + +jwt: + secret: ${JWT_SECRET} + expiration: 3600000 + refresh-token-expiration: 604800000 + +server: + port: ${SERVER_PORT:8080} + forward-headers-strategy: native + tomcat: + use-relative-redirects: true + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + show-details: when-authorized + prometheus: + enabled: true + prometheus: + metrics: + export: + enabled: true + +# OAuth2 Redirect URLs +oauth2: + redirect: + success-url: ${OAUTH2_REDIRECT_SUCCESS_URL} + failure-url: ${OAUTH2_REDIRECT_FAILURE_URL} + +fcm: + service-account-key-json: ${FCM_SERVICE_ACCOUNT_KEY_JSON} + + +cloud: + aws: + s3: + bucket: chalpu-photo-bucket + cloudfront: + domain: https://cdn.chalpu.com + region: + static: ap-northeast-2 + stack: + auto: false + +photoroom: + api: + url: https://sdk.photoroom.com/v1/segment + key: ${PHOTOROOM_API_KEY} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..caf4dfc --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: dev \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..587d916 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,40 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + http://${MONITORING_EC2_IP}:3100/loki/api/v1/push + + + + + {"timestamp":"%d{yyyy-MM-dd'T'HH:mm:ss.SSS}","level":"%level","logger":"%logger{36}","thread":"%thread","message":"%replace(%msg){'\"','\\\"'}"} + + + 1000 + 10000 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/com/example/chalpu/ChalpuApplicationTests.java b/src/test/java/com/example/chalpu/ChalpuApplicationTests.java new file mode 100644 index 0000000..dec26c5 --- /dev/null +++ b/src/test/java/com/example/chalpu/ChalpuApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.chalpu; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ChalpuApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/example/chalpu/menu/service/MenuItemServiceTest.java b/src/test/java/com/example/chalpu/menu/service/MenuItemServiceTest.java new file mode 100644 index 0000000..66177c0 --- /dev/null +++ b/src/test/java/com/example/chalpu/menu/service/MenuItemServiceTest.java @@ -0,0 +1,127 @@ +package com.example.chalpu.menu.service; + +import com.example.chalpu.common.exception.MenuException; +import com.example.chalpu.fooditem.domain.FoodItem; +import com.example.chalpu.fooditem.repository.FoodItemRepository; +import com.example.chalpu.menu.domain.Menu; +import com.example.chalpu.menu.domain.MenuItem; +import com.example.chalpu.menu.dto.MenuItemRequest; +import com.example.chalpu.menu.repository.MenuItemRepository; +import com.example.chalpu.menu.repository.MenuRepository; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.store.service.UserStoreRoleService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class MenuItemServiceTest { + + @InjectMocks + private MenuItemService menuItemService; + + @Mock + private MenuItemRepository menuItemRepository; + @Mock + private MenuRepository menuRepository; + @Mock + private FoodItemRepository foodItemRepository; + @Mock + private UserStoreRoleService userStoreRoleService; + + private Store store; + private Menu menu; + private FoodItem foodItem; + private MenuItem menuItem; + private Long userId = 1L; + private Long storeId = 1L; + private Long menuId = 1L; + private Long foodId = 1L; + private Long menuItemId = 1L; + + @BeforeEach + void setUp() { + store = Store.builder().id(storeId).build(); + menu = Menu.builder().id(menuId).store(store).menuName("기본 메뉴").isActive(true).build(); + foodItem = FoodItem.builder().id(foodId).store(store).foodName("기본 음식").build(); + menuItem = MenuItem.builder().id(menuItemId).menu(menu).foodItem(foodItem).isActive(true).build(); + } + + @Nested + @DisplayName("메뉴 아이템 추가 테스트") + class AddMenuItemTest { + @Test + @DisplayName("성공") + void addMenuItem_success() { + // given + MenuItemRequest request = new MenuItemRequest(foodId, 1); + given(menuRepository.findByIdAndIsActiveTrue(menuId)).willReturn(Optional.of(menu)); + given(foodItemRepository.findByIdAndIsActiveTrue(foodId)).willReturn(Optional.of(foodItem)); + given(userStoreRoleService.canUserManageStore(userId, storeId)).willReturn(true); + given(menuItemRepository.save(any(MenuItem.class))).willReturn(menuItem); + + // when + menuItemService.addMenuItem(menuId, request, userId); + + // then + verify(menuItemRepository).save(any(MenuItem.class)); + } + + @Test + @DisplayName("실패 - 메뉴 없음") + void addMenuItem_fail_menuNotFound() { + // given + MenuItemRequest request = new MenuItemRequest(foodId, 1); + given(menuRepository.findByIdAndIsActiveTrue(menuId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> menuItemService.addMenuItem(menuId, request, userId)) + .isInstanceOf(MenuException.class); + } + } + + @Nested + @DisplayName("메뉴 아이템 삭제 테스트") + class RemoveMenuItemTest { + @Test + @DisplayName("성공") + void removeMenuItem_success() { + // given + given(menuItemRepository.findByIdAndIsActiveTrue(menuItemId)).willReturn(Optional.of(menuItem)); + given(userStoreRoleService.canUserManageStore(userId, storeId)).willReturn(true); + + // when + menuItemService.removeMenuItem(menuId, menuItemId, userId); + + // then + verify(menuItemRepository, never()).delete(any(MenuItem.class)); + } + + @Test + @DisplayName("실패 - 메뉴 아이템이 다른 메뉴에 속함") + void removeMenuItem_fail_itemNotInMenu() { + // given + Long anotherMenuId = 2L; + given(menuItemRepository.findByIdAndIsActiveTrue(menuItemId)).willReturn(Optional.of(menuItem)); + given(userStoreRoleService.canUserManageStore(userId, storeId)).willReturn(true); + + // when & then + assertThatThrownBy(() -> menuItemService.removeMenuItem(anotherMenuId, menuItemId, userId)) + .isInstanceOf(MenuException.class); + verify(menuItemRepository, never()).delete(any(MenuItem.class)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/example/chalpu/menu/service/MenuServiceTest.java b/src/test/java/com/example/chalpu/menu/service/MenuServiceTest.java new file mode 100644 index 0000000..01df392 --- /dev/null +++ b/src/test/java/com/example/chalpu/menu/service/MenuServiceTest.java @@ -0,0 +1,152 @@ +package com.example.chalpu.menu.service; + +import com.example.chalpu.common.exception.MenuException; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.menu.domain.Menu; +import com.example.chalpu.menu.dto.MenuRequest; +import com.example.chalpu.menu.dto.MenuResponse; +import com.example.chalpu.menu.repository.MenuRepository; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.store.service.UserStoreRoleService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.Collections; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class MenuServiceTest { + + @InjectMocks + private MenuService menuService; + + @Mock + private MenuRepository menuRepository; + @Mock + private UserStoreRoleService userStoreRoleService; + @Mock + private MenuItemService menuItemService; + + private Store store; + private Menu menu; + private Long userId = 1L; + private Long storeId = 1L; + private Long menuId = 1L; + + @BeforeEach + void setUp() { + store = Store.builder().id(storeId).build(); + menu = Menu.builder().id(menuId).store(store).menuName("기본 메뉴").isActive(true).build(); + } + + @Nested + @DisplayName("메뉴 생성 테스트") + class CreateMenuTest { + @Test + @DisplayName("성공") + void createMenu_success() { + // given + MenuRequest request = new MenuRequest("새 메뉴", "설명"); + given(userStoreRoleService.canUserManageStore(userId, storeId)).willReturn(true); + given(menuRepository.save(any(Menu.class))).willReturn(menu); + + // when + MenuResponse response = menuService.createMenu(storeId, request, userId); + + // then + assertThat(response.getMenuName()).isEqualTo(menu.getMenuName()); + verify(userStoreRoleService).canUserManageStore(userId, storeId); + verify(menuRepository).save(any(Menu.class)); + } + + @Test + @DisplayName("실패 - 권한 없음") + void createMenu_fail_unauthorized() { + // given + MenuRequest request = new MenuRequest("새 메뉴", "설명"); + given(userStoreRoleService.canUserManageStore(userId, storeId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> menuService.createMenu(storeId, request, userId)) + .isInstanceOf(MenuException.class); + verify(menuRepository, never()).save(any(Menu.class)); + } + } + + @Nested + @DisplayName("메뉴 수정 테스트") + class UpdateMenuTest { + @Test + @DisplayName("성공") + void updateMenu_success() { + // given + MenuRequest request = new MenuRequest("수정된 메뉴", "수정된 설명"); + given(menuRepository.findByIdAndIsActiveTrue(menuId)).willReturn(Optional.of(menu)); + given(userStoreRoleService.canUserManageStore(userId, storeId)).willReturn(true); + + // when + MenuResponse response = menuService.updateMenu(menuId, request, userId); + + // then + assertThat(response.getMenuName()).isEqualTo("수정된 메뉴"); + assertThat(response.getIsActive()).isFalse(); + } + } + + @Nested + @DisplayName("메뉴 삭제 테스트 (소프트 딜리트)") + class DeleteMenuTest { + @Test + @DisplayName("성공") + void deleteMenu_success() { + // given + given(menuRepository.findByIdAndIsActiveTrue(menuId)).willReturn(Optional.of(menu)); + given(userStoreRoleService.canUserManageStore(userId, storeId)).willReturn(true); + + // when + menuService.deleteMenu(menuId, userId); + + // then + assertThat(menu.getIsActive()).isFalse(); + verify(userStoreRoleService).canUserManageStore(userId, storeId); + verify(menuItemService).softDeleteMenuItemsByMenu(menu); + } + } + + @Nested + @DisplayName("메뉴 목록 조회 테스트") + class GetMenusTest { + @Test + @DisplayName("성공") + void getMenus_success() { + // given + Pageable pageable = PageRequest.of(0, 10); + Page menuPage = new PageImpl<>(Collections.singletonList(menu), pageable, 1); + given(menuRepository.findByStoreIdAndIsActiveTrue(storeId, pageable)).willReturn(menuPage); + + // when + PageResponse response = menuService.getMenus(storeId, pageable); + + // then + assertThat(response.getContent()).hasSize(1); + assertThat(response.getContent().get(0).getMenuName()).isEqualTo(menu.getMenuName()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/example/chalpu/photo/service/PhotoServiceTest.java b/src/test/java/com/example/chalpu/photo/service/PhotoServiceTest.java new file mode 100644 index 0000000..cdfaeb4 --- /dev/null +++ b/src/test/java/com/example/chalpu/photo/service/PhotoServiceTest.java @@ -0,0 +1,330 @@ +package com.example.chalpu.photo.service; + +import com.example.chalpu.common.exception.ErrorMessage; +import com.example.chalpu.common.exception.PhotoException; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.photo.domain.Photo; +import com.example.chalpu.photo.dto.PhotoRegisterRequest; +import com.example.chalpu.photo.dto.PhotoResponse; +import com.example.chalpu.photo.repository.PhotoRepository; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.store.repository.StoreRepository; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PhotoService 테스트") +class PhotoServiceTest { + + @Mock + private PhotoRepository photoRepository; + + @Mock + private StoreRepository storeRepository; + + @Mock + private S3Client s3Client; + + @Mock + private S3Presigner s3Presigner; + + @InjectMocks + private PhotoService photoService; + + private Store testStore; + private Photo testPhoto; + private PhotoRegisterRequest testRegisterRequest; + + @BeforeEach + void setUp() { + // 설정값 주입 + ReflectionTestUtils.setField(photoService, "bucket", "test-bucket"); + ReflectionTestUtils.setField(photoService, "cloudfrontDomain", "https://cdn.chalpu.com"); + + // 테스트 데이터 생성 + testStore = Store.builder() + .id(1L) + .storeName("테스트 매장") + .businessType("한식") + .address("서울시 강남구") + .phone("02-1234-5678") + .businessRegistrationNumber("123-45-67890") + .build(); + + testPhoto = Photo.builder() + .id(1L) + .store(testStore) + .s3Key("foodPhoto/test-image.jpg") + .fileName("test-image.jpg") + .fileSize(1024) + .imageWidth(1920) + .imageHeight(1080) + .isActive(true) + .isFeatured(false) + .build(); + + testRegisterRequest = PhotoRegisterRequest.builder() + .s3Key("foodPhoto/test-image.jpg") + .fileName("test-image.jpg") + .storeId(1L) + .fileSize(1024) + .imageWidth(1920) + .imageHeight(1080) + .build(); + } + + @Test + @DisplayName("사진 등록 성공") + void registerPhoto_Success() { + // given + String username = "testuser"; + when(storeRepository.findById(anyLong())).thenReturn(Optional.of(testStore)); + when(photoRepository.save(any(Photo.class))).thenReturn(testPhoto); + + // when + PhotoResponse result = photoService.registerPhoto(username, testRegisterRequest); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPhotoId()).isEqualTo(1L); + assertThat(result.getStoreId()).isEqualTo(1L); + assertThat(result.getFileName()).isEqualTo("test-image.jpg"); + assertThat(result.getFileSize()).isEqualTo(1024); + assertThat(result.getImageWidth()).isEqualTo(1920); + assertThat(result.getImageHeight()).isEqualTo(1080); + assertThat(result.getIsFeatured()).isFalse(); + assertThat(result.getImageUrl()).isEqualTo("https://cdn.chalpu.com/foodPhoto/test-image.jpg"); + + verify(storeRepository).findById(1L); + verify(photoRepository).save(any(Photo.class)); + } + + @Test + @DisplayName("사진 등록 실패 - 매장 없음") + void registerPhoto_StoreNotFound_ThrowsException() { + // given + String username = "testuser"; + when(storeRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> photoService.registerPhoto(username, testRegisterRequest)) + .isInstanceOf(PhotoException.class) + .hasMessageContaining(ErrorMessage.STORE_NOT_FOUND.getMessage()); + + verify(storeRepository).findById(1L); + verify(photoRepository, never()).save(any(Photo.class)); + } + + @Test + @DisplayName("매장별 사진 목록 조회 성공") + void getPhotosByStore_Success() { + // given + Long storeId = 1L; + Pageable pageable = PageRequest.of(0, 10); + List photos = List.of(testPhoto); + Page photoPage = new PageImpl<>(photos, pageable, 1); + + when(photoRepository.findByStoreId(storeId, pageable)).thenReturn(photoPage); + + // when + PageResponse result = photoService.getPhotosByStore(storeId, pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getPage()).isEqualTo(0); + assertThat(result.getSize()).isEqualTo(10); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getTotalPages()).isEqualTo(1); + assertThat(result.isHasNext()).isFalse(); + assertThat(result.isHasPrevious()).isFalse(); + + PhotoResponse photoResponse = result.getContent().get(0); + assertThat(photoResponse.getPhotoId()).isEqualTo(1L); + assertThat(photoResponse.getStoreId()).isEqualTo(1L); + assertThat(photoResponse.getFileName()).isEqualTo("test-image.jpg"); + + verify(photoRepository).findByStoreId(storeId, pageable); + } + + @Test + @DisplayName("매장별 사진 목록 조회 - 빈 결과") + void getPhotosByStore_EmptyResult() { + // given + Long storeId = 1L; + Pageable pageable = PageRequest.of(0, 10); + Page emptyPage = new PageImpl<>(List.of(), pageable, 0); + + when(photoRepository.findByStoreId(storeId, pageable)).thenReturn(emptyPage); + + // when + PageResponse result = photoService.getPhotosByStore(storeId, pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getTotalPages()).isEqualTo(0); + + verify(photoRepository).findByStoreId(storeId, pageable); + } + + @Test + @DisplayName("사진 상세 조회 성공") + void getPhoto_Success() { + // given + Long photoId = 1L; + when(photoRepository.findById(photoId)).thenReturn(Optional.of(testPhoto)); + + // when + PhotoResponse result = photoService.getPhoto(photoId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPhotoId()).isEqualTo(1L); + assertThat(result.getStoreId()).isEqualTo(1L); + assertThat(result.getFileName()).isEqualTo("test-image.jpg"); + assertThat(result.getFileSize()).isEqualTo(1024); + assertThat(result.getImageWidth()).isEqualTo(1920); + assertThat(result.getImageHeight()).isEqualTo(1080); + assertThat(result.getIsFeatured()).isFalse(); + assertThat(result.getImageUrl()).isEqualTo("https://cdn.chalpu.com/foodPhoto/test-image.jpg"); + + verify(photoRepository).findById(photoId); + } + + @Test + @DisplayName("사진 상세 조회 실패 - 사진 없음") + void getPhoto_PhotoNotFound_ThrowsException() { + // given + Long photoId = 1L; + when(photoRepository.findById(photoId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> photoService.getPhoto(photoId)) + .isInstanceOf(PhotoException.class) + .hasMessageContaining(ErrorMessage.PHOTO_NOT_FOUND.getMessage()); + + verify(photoRepository).findById(photoId); + } + + @Test + @DisplayName("사진 삭제 성공") + void deletePhoto_Success() { + // given + String username = "testuser"; + Long photoId = 1L; + when(photoRepository.findById(photoId)).thenReturn(Optional.of(testPhoto)); + + // when + photoService.deletePhoto(username, photoId); + + // then + assertThat(testPhoto.getIsActive()).isFalse(); + + verify(photoRepository).findById(photoId); + // S3 관련 부분은 패스 (S3Client 호출 검증 생략) + } + + @Test + @DisplayName("사진 삭제 실패 - 사진 없음") + void deletePhoto_PhotoNotFound_ThrowsException() { + // given + String username = "testuser"; + Long photoId = 1L; + when(photoRepository.findById(photoId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> photoService.deletePhoto(username, photoId)) + .isInstanceOf(PhotoException.class) + .hasMessageContaining(ErrorMessage.PHOTO_NOT_FOUND.getMessage()); + + verify(photoRepository).findById(photoId); + // S3 관련 부분은 패스 (S3Client 호출 검증 생략) + } + + @Test + @DisplayName("사진 등록 시 FoodItem이 있는 경우") + void registerPhoto_WithFoodItem_Success() { + // given + String username = "testuser"; + PhotoRegisterRequest requestWithFoodItem = PhotoRegisterRequest.builder() + .s3Key("foodPhoto/test-image.jpg") + .fileName("test-image.jpg") + .storeId(1L) + .foodItemId(10L) + .fileSize(1024) + .imageWidth(1920) + .imageHeight(1080) + .build(); + + when(storeRepository.findById(anyLong())).thenReturn(Optional.of(testStore)); + when(photoRepository.save(any(Photo.class))).thenReturn(testPhoto); + + // when + PhotoResponse result = photoService.registerPhoto(username, requestWithFoodItem); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPhotoId()).isEqualTo(1L); + assertThat(result.getStoreId()).isEqualTo(1L); + + verify(storeRepository).findById(1L); + verify(photoRepository).save(any(Photo.class)); + } + + @Test + @DisplayName("CloudFront 도메인이 null인 경우 URL 생성") + void photoResponse_NullCloudfrontDomain_ReturnsNullUrl() { + // given + ReflectionTestUtils.setField(photoService, "cloudfrontDomain", null); + when(photoRepository.findById(anyLong())).thenReturn(Optional.of(testPhoto)); + + // when + PhotoResponse result = photoService.getPhoto(1L); + + // then + assertThat(result.getImageUrl()).isNull(); + + verify(photoRepository).findById(1L); + } + + @Test + @DisplayName("CloudFront 도메인이 슬래시로 끝나는 경우 URL 생성") + void photoResponse_CloudfrontDomainWithSlash_CreatesCorrectUrl() { + // given + ReflectionTestUtils.setField(photoService, "cloudfrontDomain", "https://cdn.chalpu.com/"); + when(photoRepository.findById(anyLong())).thenReturn(Optional.of(testPhoto)); + + // when + PhotoResponse result = photoService.getPhoto(1L); + + // then + assertThat(result.getImageUrl()).isEqualTo("https://cdn.chalpu.com/foodPhoto/test-image.jpg"); + + verify(photoRepository).findById(1L); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/chalpu/store/service/UserStoreRoleServiceTest.java b/src/test/java/com/example/chalpu/store/service/UserStoreRoleServiceTest.java new file mode 100644 index 0000000..383c9b3 --- /dev/null +++ b/src/test/java/com/example/chalpu/store/service/UserStoreRoleServiceTest.java @@ -0,0 +1,490 @@ +package com.example.chalpu.store.service; + +import com.example.chalpu.common.exception.StoreException; +import com.example.chalpu.common.response.PageResponse; +import com.example.chalpu.store.domain.Store; +import com.example.chalpu.store.domain.StoreRoleType; +import com.example.chalpu.store.domain.UserStoreRole; +import com.example.chalpu.store.dto.MemberInviteRequest; +import com.example.chalpu.store.dto.MemberResponse; +import com.example.chalpu.store.dto.StoreResponse; +import com.example.chalpu.store.repository.StoreRepository; +import com.example.chalpu.store.repository.UserStoreRoleRepository; +import com.example.chalpu.user.domain.User; +import com.example.chalpu.user.repository.UserRepository; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserStoreRoleServiceTest { + + @Mock + private UserStoreRoleRepository userStoreRoleRepository; + + @Mock + private StoreRepository storeRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserStoreRoleService userStoreRoleService; + + private User testUser; + private Store testStore; + private UserStoreRole testUserStoreRole; + private Pageable testPageable; + + @BeforeEach + void setUp() { + testUser = User.builder() + .id(1L) + .email("test@example.com") + .name("테스트 사용자") + .build(); + + testStore = Store.builder() + .id(1L) + .storeName("테스트 매장") + .businessType("한식") + .address("서울시 강남구") + .phone("02-1234-5678") + .businessRegistrationNumber("123-45-67890") + .build(); + + testUserStoreRole = UserStoreRole.builder() + .id(1L) + .user(testUser) + .store(testStore) + .roleType(StoreRoleType.OWNER) + .isActive(true) + .build(); + + testPageable = PageRequest.of(0, 10); + } + + @Test + @DisplayName("사용자가 속한 매장 목록 조회") + void getMyStores_WithPagination_Success() { + // given + List userStoreRoles = List.of(testUserStoreRole); + Page userStoreRolePage = new PageImpl<>(userStoreRoles, testPageable, 1); + + when(userStoreRoleRepository.findByUserId(anyLong(), any(Pageable.class))) + .thenReturn(userStoreRolePage); + + // when + PageResponse result = userStoreRoleService.getMyStores(1L, testPageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getStoreName()).isEqualTo("테스트 매장"); + + verify(userStoreRoleRepository).findByUserId(1L, testPageable); + } + + @Test + @DisplayName("사용자가 속한 매장 목록 조회 (전체) - 성공") + void getMyStores_All_Success() { + // given + List userStoreRoles = List.of(testUserStoreRole); + + when(userStoreRoleRepository.findByUserId(anyLong())) + .thenReturn(userStoreRoles); + + // when + List result = userStoreRoleService.getMyStores(1L); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getStoreName()).isEqualTo("테스트 매장"); + + verify(userStoreRoleRepository).findByUserId(1L); + } + + @Test + @DisplayName("사용자가 소유한 매장 목록 조회") + void getOwnedStores_Success() { + // given + List userStoreRoles = List.of(testUserStoreRole); + + when(userStoreRoleRepository.findByUserId(anyLong())) + .thenReturn(userStoreRoles); + + // when + List result = userStoreRoleService.getOwnedStores(1L); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getStoreName()).isEqualTo("테스트 매장"); + + verify(userStoreRoleRepository).findByUserId(1L); + } + + @Test + @DisplayName("사용자가 관리할 수 있는 매장 목록 조회 - 성공") + void getManageableStores_Success() { + // given + List userStoreRoles = List.of(testUserStoreRole); + + when(userStoreRoleRepository.findByUserId(anyLong())) + .thenReturn(userStoreRoles); + + // when + List result = userStoreRoleService.getManageableStores(1L); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getStoreName()).isEqualTo("테스트 매장"); + + verify(userStoreRoleRepository).findByUserId(1L); + } + + @Test + @DisplayName("매장 소유자 역할 생성 (사용자 ID와 매장 ID로) - 성공") + void createOwnerRole_WithIds_Success() { + // given + when(userRepository.findById(anyLong())).thenReturn(Optional.of(testUser)); + when(storeRepository.findById(anyLong())).thenReturn(Optional.of(testStore)); + when(userStoreRoleRepository.save(any(UserStoreRole.class))).thenReturn(testUserStoreRole); + + // when + userStoreRoleService.createOwnerRole(1L, 1L); + + // then + verify(userRepository).findById(1L); + verify(storeRepository).findById(1L); + verify(userStoreRoleRepository).save(any(UserStoreRole.class)); + } + + @Test + @DisplayName("매장 소유자 역할 생성 - 사용자 없음 예외") + void createOwnerRole_UserNotFound_ThrowsException() { + // given + when(userRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStoreRoleService.createOwnerRole(1L, 1L)) + .isInstanceOf(StoreException.class); + + verify(userRepository).findById(1L); + verify(storeRepository, never()).findById(anyLong()); + verify(userStoreRoleRepository, never()).save(any(UserStoreRole.class)); + } + + @Test + @DisplayName("매장 소유자 역할 생성 - 매장 없음 예외") + void createOwnerRole_StoreNotFound_ThrowsException() { + // given + when(userRepository.findById(anyLong())).thenReturn(Optional.of(testUser)); + when(storeRepository.findById(anyLong())).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStoreRoleService.createOwnerRole(1L, 1L)) + .isInstanceOf(StoreException.class); + + verify(userRepository).findById(1L); + verify(storeRepository).findById(1L); + verify(userStoreRoleRepository, never()).save(any(UserStoreRole.class)); + } + + @Test + @DisplayName("매장에 멤버 초대 - 성공") + void inviteMember_Success() { + // given + MemberInviteRequest request = new MemberInviteRequest(); + request.setUserId(2L); + request.setRoleType(StoreRoleType.STAFF); + + User inviteUser = User.builder().id(2L).email("invite@example.com").name("초대 사용자").build(); + UserStoreRole newRole = UserStoreRole.builder() + .id(2L) + .user(inviteUser) + .store(testStore) + .roleType(StoreRoleType.STAFF) + .isActive(true) + .build(); + + when(storeRepository.findById(anyLong())).thenReturn(Optional.of(testStore)); + when(userRepository.findById(anyLong())).thenReturn(Optional.of(inviteUser)); + when(userStoreRoleRepository.findByUserId(anyLong())).thenReturn(List.of(testUserStoreRole)); + when(userStoreRoleRepository.findByUserIdAndStoreId(anyLong(), anyLong())).thenReturn(Optional.empty()); + when(userStoreRoleRepository.save(any(UserStoreRole.class))).thenReturn(newRole); + + // when + MemberResponse result = userStoreRoleService.inviteMember(1L, request, 1L); + + // then + assertThat(result).isNotNull(); + assertThat(result.getUserId()).isEqualTo(2L); + + verify(storeRepository).findById(1L); + verify(userRepository).findById(2L); + verify(userStoreRoleRepository).save(any(UserStoreRole.class)); + } + + @Test + @DisplayName("매장에 멤버 초대 - 이미 존재하는 멤버 예외") + void inviteMember_AlreadyExists_ThrowsException() { + // given + MemberInviteRequest request = new MemberInviteRequest(); + request.setUserId(2L); + request.setRoleType(StoreRoleType.STAFF); + + User inviteUser = User.builder().id(2L).email("invite@example.com").name("초대 사용자").build(); + UserStoreRole existingRole = UserStoreRole.builder() + .id(2L) + .user(inviteUser) + .store(testStore) + .roleType(StoreRoleType.STAFF) + .isActive(true) + .build(); + + when(storeRepository.findById(anyLong())).thenReturn(Optional.of(testStore)); + when(userRepository.findById(anyLong())).thenReturn(Optional.of(inviteUser)); + when(userStoreRoleRepository.findByUserId(anyLong())).thenReturn(List.of(testUserStoreRole)); + when(userStoreRoleRepository.findByUserIdAndStoreId(anyLong(), anyLong())).thenReturn(Optional.of(existingRole)); + + // when & then + assertThatThrownBy(() -> userStoreRoleService.inviteMember(1L, request, 1L)) + .isInstanceOf(StoreException.class); + + verify(userStoreRoleRepository, never()).save(any(UserStoreRole.class)); + } + + @Test + @DisplayName("멤버 역할 변경 - 성공") + void changeRole_Success() { + // given + UserStoreRole requestUserRole = testUserStoreRole; // user 1, OWNER + UserStoreRole targetRole = UserStoreRole.builder() + .id(2L) + .user(User.builder().id(2L).build()) + .store(testStore) + .roleType(StoreRoleType.STAFF) + .isActive(true) + .build(); + + when(storeRepository.findById(1L)).thenReturn(Optional.of(testStore)); + when(userStoreRoleRepository.findByUserIdAndStoreId(1L, 1L)).thenReturn(Optional.of(requestUserRole)); + when(userStoreRoleRepository.findByUserIdAndStoreId(2L, 1L)).thenReturn(Optional.of(targetRole)); + when(userStoreRoleRepository.save(any(UserStoreRole.class))).thenReturn(targetRole); + + // when + MemberResponse result = userStoreRoleService.changeRole(1L, 2L, StoreRoleType.MANAGER, 1L); + + // then + assertThat(result).isNotNull(); + assertThat(result.getRoleType()).isEqualTo(StoreRoleType.MANAGER); + + verify(userStoreRoleRepository).save(any(UserStoreRole.class)); + } + + @Test + @DisplayName("멤버 제거 - 성공") + void removeMember_Success() { + // given + UserStoreRole targetRole = UserStoreRole.builder() + .id(2L) + .user(User.builder().id(2L).build()) + .store(testStore) + .roleType(StoreRoleType.STAFF) + .isActive(true) + .build(); + + when(storeRepository.findById(1L)).thenReturn(Optional.of(testStore)); + when(userStoreRoleRepository.findByUserId(1L)).thenReturn(List.of(testUserStoreRole)); + when(userStoreRoleRepository.findByUserIdAndStoreId(2L, 1L)).thenReturn(Optional.of(targetRole)); + when(userStoreRoleRepository.save(any(UserStoreRole.class))).thenReturn(targetRole); + + // when + userStoreRoleService.removeMember(1L, 2L, 1L); + + // then + verify(userStoreRoleRepository).save(targetRole); + assertThat(targetRole.getIsActive()).isFalse(); + } + + @Test + @DisplayName("매장 탈퇴 - 성공") + void leaveStore_Success() { + // given + UserStoreRole userRole = UserStoreRole.builder() + .id(1L) + .user(testUser) + .store(testStore) + .roleType(StoreRoleType.STAFF) // 소유자가 아닌 직원 + .isActive(true) + .build(); + + when(userStoreRoleRepository.findByUserIdAndStoreId(1L, 1L)).thenReturn(Optional.of(userRole)); + when(userStoreRoleRepository.save(any(UserStoreRole.class))).thenReturn(userRole); + + // when + userStoreRoleService.leaveStore(1L, 1L); + + // then + verify(userStoreRoleRepository).save(userRole); + assertThat(userRole.getIsActive()).isFalse(); + } + + @Test + @DisplayName("매장 탈퇴 - 소유자는 탈퇴 불가 예외") + void leaveStore_OwnerCannotLeave_ThrowsException() { + // given + UserStoreRole ownerRole = UserStoreRole.builder() + .id(1L) + .user(testUser) + .store(testStore) + .roleType(StoreRoleType.OWNER) // 소유자 + .isActive(true) + .build(); + + when(userStoreRoleRepository.findByUserIdAndStoreId(anyLong(), anyLong())).thenReturn(Optional.of(ownerRole)); + + // when & then + assertThatThrownBy(() -> userStoreRoleService.leaveStore(1L, 1L)) + .isInstanceOf(StoreException.class); + + verify(userStoreRoleRepository, never()).save(any(UserStoreRole.class)); + } + + @Test + @DisplayName("사용자 매장 접근 권한 확인 - 성공") + void canUserAccessStore_Success() { + // given + when(storeRepository.findById(anyLong())).thenReturn(Optional.of(testStore)); + when(userStoreRoleRepository.findByUserId(anyLong())).thenReturn(List.of(testUserStoreRole)); + + // when + boolean result = userStoreRoleService.canUserAccessStore(1L, 1L); + + // then + assertThat(result).isTrue(); + + verify(storeRepository).findById(1L); + verify(userStoreRoleRepository).findByUserId(1L); + } + + @Test + @DisplayName("사용자 매장 관리 권한 확인 - 성공") + void canUserManageStore_Success() { + // given + when(storeRepository.findById(anyLong())).thenReturn(Optional.of(testStore)); + when(userStoreRoleRepository.findByUserId(anyLong())).thenReturn(List.of(testUserStoreRole)); + + // when + boolean result = userStoreRoleService.canUserManageStore(1L, 1L); + + // then + assertThat(result).isTrue(); + + verify(storeRepository).findById(1L); + verify(userStoreRoleRepository).findByUserId(1L); + } + + @Test + @DisplayName("사용자가 소유한 매장 목록 조회 - 소유한 매장 없음") + void getOwnedStores_NoOwnedStores_ReturnsEmptyList() { + // given + UserStoreRole staffRole = UserStoreRole.builder() + .roleType(StoreRoleType.STAFF) + .build(); + + when(userStoreRoleRepository.findByUserId(anyLong())) + .thenReturn(List.of(staffRole)); + + // when + List result = userStoreRoleService.getOwnedStores(1L); + + // then + assertThat(result).isEmpty(); + verify(userStoreRoleRepository).findByUserId(1L); + } + + @Test + @DisplayName("사용자가 관리할 수 있는 매장 목록 조회 - 관리 가능 매장 없음") + void getManageableStores_NoManageableStores_ReturnsEmptyList() { + // given + UserStoreRole staffRole = UserStoreRole.builder() + .roleType(StoreRoleType.STAFF) + .build(); + + when(userStoreRoleRepository.findByUserId(anyLong())) + .thenReturn(List.of(staffRole)); + + // when + List result = userStoreRoleService.getManageableStores(1L); + + // then + assertThat(result).isEmpty(); + verify(userStoreRoleRepository).findByUserId(1L); + } + + @Test + @DisplayName("매장에 멤버 초대 - 권한 없음 예외") + void inviteMember_NoPermission_ThrowsException() { + // given + MemberInviteRequest request = new MemberInviteRequest(); + request.setUserId(2L); + request.setRoleType(StoreRoleType.STAFF); + + UserStoreRole inviterRole = UserStoreRole.builder() + .user(testUser) + .store(testStore) + .roleType(StoreRoleType.STAFF) // 초대 권한이 없는 STAFF + .isActive(true) + .build(); + + when(storeRepository.findById(anyLong())).thenReturn(Optional.of(testStore)); + when(userRepository.findById(anyLong())).thenReturn(Optional.of(User.builder().id(2L).build())); + when(userStoreRoleRepository.findByUserId(anyLong())).thenReturn(List.of(inviterRole)); + + // when & then + assertThatThrownBy(() -> userStoreRoleService.inviteMember(1L, request, 1L)) + .isInstanceOf(StoreException.class); + + verify(userStoreRoleRepository, never()).save(any(UserStoreRole.class)); + } + + @Test + @DisplayName("멤버 제거 - 권한 없음 예외") + void removeMember_NoPermission_ThrowsException() { + // given + UserStoreRole removerRole = UserStoreRole.builder() + .user(testUser) + .store(testStore) + .roleType(StoreRoleType.STAFF) // 제거 권한이 없는 STAFF + .isActive(true) + .build(); + + when(storeRepository.findById(anyLong())).thenReturn(Optional.of(testStore)); + when(userStoreRoleRepository.findByUserId(anyLong())).thenReturn(List.of(removerRole)); + + // when & then + assertThatThrownBy(() -> userStoreRoleService.removeMember(1L, 2L, 1L)) + .isInstanceOf(StoreException.class); + + verify(userStoreRoleRepository, never()).findByUserIdAndStoreId(anyLong(), anyLong()); + verify(userStoreRoleRepository, never()).save(any(UserStoreRole.class)); + } +} \ No newline at end of file