diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52b6a14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,194 @@ +# Created by https://www.toptal.com/developers/gitignore/api/intellij+all,macos,gradle,kotlin,java +# Edit at https://www.toptal.com/developers/gitignore?templates=intellij+all,macos,gradle,kotlin,java + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Kotlin ### +# Compiled class file + +# Log file + +# BlueJ files + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/intellij+all,macos,gradle,kotlin,java +docker/data +src/main/resources/application.yml + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e6ae35 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# devooks-backend diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c44c09d --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,112 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("org.springframework.boot") version "3.2.3" + id("io.spring.dependency-management") version "1.1.4" + kotlin("plugin.serialization") version "1.8.0" + kotlin("jvm") version "1.9.22" + kotlin("plugin.spring") version "1.9.22" + id("com.google.osdetector") version "1.7.0" +} + +group = "com.devooks" +version = "0.0.1" + +java { + sourceCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +dependencies { + // spring + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + if (osdetector.arch.equals("aarch_64")) { + implementation("io.netty:netty-resolver-dns-native-macos:4.1.89.Final:osx-aarch_64") + } + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor:3.0.4") + + // r2dbc + implementation("org.springframework.boot:spring-boot-starter-data-r2dbc:3.0.4") + implementation("org.postgresql:r2dbc-postgresql:1.0.1.RELEASE") + runtimeOnly("org.postgresql:postgresql") + + // feign client + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer") + + // kotlin + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") + + // validation + implementation("org.hibernate.validator:hibernate-validator:8.0.0.Final") + + // jwt + val jwtVersion = "0.11.5" + implementation("io.jsonwebtoken:jjwt-api:$jwtVersion") + runtimeOnly("io.jsonwebtoken:jjwt-impl:$jwtVersion") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jwtVersion") + + // pdf + implementation("org.apache.pdfbox:pdfbox:2.0.27") + + // test + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("io.projectreactor:reactor-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") + + // test container + val testContainerVersion = "1.19.4" + testImplementation("org.testcontainers:testcontainers:$testContainerVersion") + testImplementation("org.testcontainers:r2dbc:$testContainerVersion") + testImplementation("org.testcontainers:postgresql:$testContainerVersion") + testImplementation("org.testcontainers:junit-jupiter:$testContainerVersion") + + // swagger + implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.1.0") + + // json + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3") + + // coroutines test + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0-RC") +} + +val springCloudVersion by extra("2023.0.0") +// feign client +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion") + } +} + + +tasks.withType { + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + jvmTarget = "17" + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.register("copyJar", Copy::class) { + dependsOn("bootJar") + val jarFile = "devooks-0.0.1.jar" + from("build/libs") + into(file("docker")) + include(jarFile) +} + +tasks.named("build") { + dependsOn("copyJar") +} \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..75b2a33 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17-jdk-slim +WORKDIR /app +COPY ./devooks-1.0.0.jar app.jar +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..4c2983c --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.9' + +services: + devooks-database: + image: postgres:14 + container_name: devooks-database + environment: + POSTGRES_DB: "devooksdb" + POSTGRES_USER: "devooks" + POSTGRES_PASSWORD: "devooks" + volumes: + - ./data:/var/lib/postgresql/data + ports: + - "35432:5432" + devooks-application: + image: devooks-backend-app:latest + build: + context: . + container_name: devooks-application + depends_on: + - devooks-database + environment: + - DATABASE_URL=r2dbc:postgresql://devooks-database:5432/devooksdb + volumes: + - ./static:/app/static + ports: + - "80:8081" \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 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..1af9e09 --- /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.5-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..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/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. +# + +############################################################################## +# +# 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/subprojects/plugins/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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@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 + +@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. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d222297 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "devooks" diff --git a/src/main/kotlin/com/devooks/backend/DevooksBackendApplication.kt b/src/main/kotlin/com/devooks/backend/DevooksBackendApplication.kt new file mode 100644 index 0000000..fa7fbaf --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/DevooksBackendApplication.kt @@ -0,0 +1,37 @@ +package com.devooks.backend + +import com.devooks.backend.common.utils.createDirectory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.cloud.openfeign.EnableFeignClients + +@EnableFeignClients +@SpringBootApplication +@ConfigurationPropertiesScan +class BackendApplication { + companion object { + const val STATIC_ROOT_PATH = "static" + const val PROFILE_IMAGE_ROOT_PATH = "$STATIC_ROOT_PATH/profile-image" + const val MAIN_IMAGE_ROOT_PATH = "$STATIC_ROOT_PATH/main-image" + const val PDF_ROOT_PATH = "$STATIC_ROOT_PATH/pdf" + const val PREVIEW_IMAGE_ROOT_PATH = "$STATIC_ROOT_PATH/preview" + const val DESCRIPTION_IMAGE_ROOT_PATH = "$STATIC_ROOT_PATH/description-image" + const val SERVICE_INQUIRY_IMAGE_ROOT_PATH = "$STATIC_ROOT_PATH/service-inquiry-image" + + fun createDirectories() { + createDirectory(STATIC_ROOT_PATH) + createDirectory(PROFILE_IMAGE_ROOT_PATH) + createDirectory(MAIN_IMAGE_ROOT_PATH) + createDirectory(PDF_ROOT_PATH) + createDirectory(PREVIEW_IMAGE_ROOT_PATH) + createDirectory(DESCRIPTION_IMAGE_ROOT_PATH) + createDirectory(SERVICE_INQUIRY_IMAGE_ROOT_PATH) + } + } +} + +fun main(args: Array) { + BackendApplication.createDirectories() + runApplication(*args) +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/google/GoogleOauthClient.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/GoogleOauthClient.kt new file mode 100644 index 0000000..807e0ef --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/GoogleOauthClient.kt @@ -0,0 +1,25 @@ +package com.devooks.backend.auth.v1.client.google + +import com.devooks.backend.auth.v1.client.google.dto.GetGoogleTokenResponse +import com.devooks.backend.auth.v1.config.feign.FormFeignConfig +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod + +@FeignClient( + name = "googleOauthClient", + url = "\${google.oauthHost}", + configuration = [FormFeignConfig::class] +) +interface GoogleOauthClient { + + @RequestMapping( + method = [RequestMethod.POST], + value = ["\${google.tokenUrl}"], + consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE], + headers = ["Content-Type: ${MediaType.APPLICATION_FORM_URLENCODED_VALUE}"] + ) + fun getToken(request: String): GetGoogleTokenResponse + +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/google/GoogleProfileClient.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/GoogleProfileClient.kt new file mode 100644 index 0000000..0bbd464 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/GoogleProfileClient.kt @@ -0,0 +1,24 @@ +package com.devooks.backend.auth.v1.client.google + +import com.devooks.backend.auth.v1.client.google.dto.GetGoogleProfileResponse +import com.devooks.backend.auth.v1.config.feign.BasicFeignConfig +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod + + +@FeignClient( + name = "googleProfileClient", + url = "\${google.profileHost}", + configuration = [BasicFeignConfig::class] +) +interface GoogleProfileClient { + @RequestMapping( + method = [RequestMethod.GET], + value = ["\${google.profileUrl}"], + ) + fun getOauthId( + @RequestHeader("Authorization") token: String + ): GetGoogleProfileResponse +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/google/dto/GetGoogleProfileResponse.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/dto/GetGoogleProfileResponse.kt new file mode 100644 index 0000000..2f41dda --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/dto/GetGoogleProfileResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.auth.v1.client.google.dto + +data class GetGoogleProfileResponse( + val id: String?, +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/google/dto/GetGoogleTokenRequest.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/dto/GetGoogleTokenRequest.kt new file mode 100644 index 0000000..26576c6 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/dto/GetGoogleTokenRequest.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.auth.v1.client.google.dto + +class GetGoogleTokenRequest( + val grantType: String, + val clientId: String, + val clientSecret: String, + val redirectUri: String, + val code: String, +) { + override fun toString(): String { + return "grant_type=${grantType}&client_id=${clientId}&client_secret=${clientSecret}" + + "&redirect_uri=${redirectUri}&code=${code}" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/google/dto/GetGoogleTokenResponse.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/dto/GetGoogleTokenResponse.kt new file mode 100644 index 0000000..6567ea9 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/google/dto/GetGoogleTokenResponse.kt @@ -0,0 +1,8 @@ +package com.devooks.backend.auth.v1.client.google.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetGoogleTokenResponse( + @JsonProperty("access_token") + val accessToken: String? +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/KakaoOauthClient.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/KakaoOauthClient.kt new file mode 100644 index 0000000..410e165 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/KakaoOauthClient.kt @@ -0,0 +1,24 @@ +package com.devooks.backend.auth.v1.client.kakao + +import com.devooks.backend.auth.v1.client.kakao.dto.GetKakaoTokenResponse +import com.devooks.backend.auth.v1.config.feign.FormFeignConfig +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod + +@FeignClient( + name = "kakaoOauthClient", + url = "\${kakao.oauthHost}", + configuration = [FormFeignConfig::class] +) +interface KakaoOauthClient { + + @RequestMapping( + method = [RequestMethod.POST], + value = ["\${kakao.tokenUrl}"], + consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE], + headers = ["Content-Type: ${MediaType.APPLICATION_FORM_URLENCODED_VALUE}"] + ) + fun getToken(request: String): GetKakaoTokenResponse +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/KakaoProfileClient.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/KakaoProfileClient.kt new file mode 100644 index 0000000..b2a3a18 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/KakaoProfileClient.kt @@ -0,0 +1,26 @@ +package com.devooks.backend.auth.v1.client.kakao + +import com.devooks.backend.auth.v1.client.kakao.dto.GetKakaoProfileResponse +import com.devooks.backend.auth.v1.config.feign.BasicFeignConfig +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod + +@FeignClient( + name = "kakaoProfileClient", + url = "\${kakao.profileHost}", + configuration = [BasicFeignConfig::class] +) +interface KakaoProfileClient { + + @RequestMapping( + method = [RequestMethod.GET], + value = ["\${kakao.profileUrl}"], + headers = ["Content-Type: ${MediaType.APPLICATION_FORM_URLENCODED_VALUE}"] + ) + fun getOauthId( + @RequestHeader("Authorization") token: String, + ): GetKakaoProfileResponse +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/dto/GetKakaoProfileResponse.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/dto/GetKakaoProfileResponse.kt new file mode 100644 index 0000000..dcbac99 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/dto/GetKakaoProfileResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.auth.v1.client.kakao.dto + +data class GetKakaoProfileResponse( + val id: Long?, +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/dto/GetKakaoTokenRequest.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/dto/GetKakaoTokenRequest.kt new file mode 100644 index 0000000..1661467 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/dto/GetKakaoTokenRequest.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.auth.v1.client.kakao.dto + +class GetKakaoTokenRequest( + val grantType: String, + val clientId: String, + val redirectUri: String, + val code: String, +) { + override fun toString(): String { + return "grant_type=${grantType}&client_id=${clientId}&redirect_uri=${redirectUri}&code=${code}" + } +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/dto/GetKakaoTokenResponse.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/dto/GetKakaoTokenResponse.kt new file mode 100644 index 0000000..2af3f87 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/kakao/dto/GetKakaoTokenResponse.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.auth.v1.client.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetKakaoTokenResponse( + @JsonProperty("token_type") + val tokenType: String?, + @JsonProperty("access_token") + val accessToken: String?, + @JsonProperty("expires_in") + val expiresIn: Int?, + @JsonProperty("refresh_token") + val refreshToken: String?, + @JsonProperty("refresh_token_expires_in") + val refreshTokenExpiresIn: Int?, +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/NaverOauthClient.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/NaverOauthClient.kt new file mode 100644 index 0000000..e9a700e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/NaverOauthClient.kt @@ -0,0 +1,25 @@ +package com.devooks.backend.auth.v1.client.naver + +import com.devooks.backend.auth.v1.client.naver.dto.GetNaverTokenResponse +import com.devooks.backend.auth.v1.config.feign.BasicFeignConfig +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod +import org.springframework.web.bind.annotation.RequestParam + +@FeignClient( + name = "naverOauthClient", + url = "\${naver.oauthHost}", + configuration = [BasicFeignConfig::class] +) +interface NaverOauthClient { + + @RequestMapping(method = [RequestMethod.GET], value = ["\${naver.tokenUrl}"]) + fun getToken( + @RequestParam("grant_type") grantType: String, + @RequestParam("client_id") clientId: String, + @RequestParam("client_secret") clientSecret: String, + @RequestParam("code") code: String, + @RequestParam("state") state: String, + ): GetNaverTokenResponse +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/NaverProfileClient.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/NaverProfileClient.kt new file mode 100644 index 0000000..93039c4 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/NaverProfileClient.kt @@ -0,0 +1,19 @@ +package com.devooks.backend.auth.v1.client.naver + +import com.devooks.backend.auth.v1.client.naver.dto.GetNaverProfileResponse +import com.devooks.backend.auth.v1.config.feign.BasicFeignConfig +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod + +@FeignClient( + name = "naverProfileClient", + url = "\${naver.profileHost}", + configuration = [BasicFeignConfig::class] +) +interface NaverProfileClient { + + @RequestMapping(method = [RequestMethod.GET], value = ["\${naver.profileUrl}"]) + fun getOauthId(@RequestHeader("Authorization") token: String): GetNaverProfileResponse +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/dto/GetNaverProfileResponse.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/dto/GetNaverProfileResponse.kt new file mode 100644 index 0000000..91fd1aa --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/dto/GetNaverProfileResponse.kt @@ -0,0 +1,20 @@ +package com.devooks.backend.auth.v1.client.naver.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetNaverProfileResponse( + @JsonProperty("resultcode") + val resultCode: String, + @JsonProperty("message") + val message: String, + @JsonProperty("response") + val profile: Profile?, +) { + + data class Profile( + @JsonProperty("id") + val id: String?, + @JsonProperty("email") + val email: String?, + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/dto/GetNaverTokenResponse.kt b/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/dto/GetNaverTokenResponse.kt new file mode 100644 index 0000000..14db269 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/client/naver/dto/GetNaverTokenResponse.kt @@ -0,0 +1,20 @@ +package com.devooks.backend.auth.v1.client.naver.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetNaverTokenResponse( + @JsonProperty("access_token") + private val accessToken: String?, + @JsonProperty("refresh_token") + val refreshToken: String?, + @JsonProperty("token_token") + val tokenType: String?, + @JsonProperty("expires_in") + val expiresIn: String?, + @JsonProperty("error") + val error: String?, + @JsonProperty("error_description") + val errorDescription: String?, +) { + val token: String? = accessToken?.let { "Bearer $accessToken" } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/config/JwtConfigProperties.kt b/src/main/kotlin/com/devooks/backend/auth/v1/config/JwtConfigProperties.kt new file mode 100644 index 0000000..7b4b511 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/config/JwtConfigProperties.kt @@ -0,0 +1,10 @@ +package com.devooks.backend.auth.v1.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "jwt") +data class JwtConfigProperties( + val secretKey: String, + val accessTokenExpirationHour: Int, + val refreshTokenExpirationHour: Int, +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/config/OpenApiConfiguration.kt b/src/main/kotlin/com/devooks/backend/auth/v1/config/OpenApiConfiguration.kt new file mode 100644 index 0000000..833b645 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/config/OpenApiConfiguration.kt @@ -0,0 +1,36 @@ +package com.devooks.backend.auth.v1.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.security.SecurityScheme.Type.HTTP +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfiguration { + + companion object { + private const val SECURITY_SCHEME_NAME = "bearerAuth" + } + + @Bean + fun customizeOpenApi(): OpenAPI = + OpenAPI() + .addSecurityItem( + SecurityRequirement().addList(SECURITY_SCHEME_NAME) + ) + .components( + Components() + .addSecuritySchemes( + SECURITY_SCHEME_NAME, + SecurityScheme() + .name(SECURITY_SCHEME_NAME) + .type(HTTP) + .scheme("Bearer") + .bearerFormat("JWT") + ) + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/config/feign/BasicFeignConfig.kt b/src/main/kotlin/com/devooks/backend/auth/v1/config/feign/BasicFeignConfig.kt new file mode 100644 index 0000000..8c9c67c --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/config/feign/BasicFeignConfig.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.auth.v1.config.feign + +import feign.codec.Decoder +import feign.codec.Encoder +import org.springframework.boot.autoconfigure.http.HttpMessageConverters +import org.springframework.cloud.openfeign.support.SpringDecoder +import org.springframework.cloud.openfeign.support.SpringEncoder +import org.springframework.context.annotation.Bean + +class BasicFeignConfig { + + @Bean + fun feignEncoder(): Encoder = SpringEncoder { HttpMessageConverters() } + + @Bean + fun feignDecoder(): Decoder = SpringDecoder { HttpMessageConverters() } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/config/feign/FormFeignConfig.kt b/src/main/kotlin/com/devooks/backend/auth/v1/config/feign/FormFeignConfig.kt new file mode 100644 index 0000000..04b8c29 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/config/feign/FormFeignConfig.kt @@ -0,0 +1,19 @@ +package com.devooks.backend.auth.v1.config.feign + +import feign.codec.Decoder +import feign.codec.Encoder +import feign.form.spring.SpringFormEncoder +import org.springframework.boot.autoconfigure.http.HttpMessageConverters +import org.springframework.cloud.openfeign.support.SpringDecoder +import org.springframework.cloud.openfeign.support.SpringEncoder +import org.springframework.context.annotation.Bean + +class FormFeignConfig { + + @Bean + fun feignEncoder(): Encoder = SpringFormEncoder(SpringEncoder { HttpMessageConverters() }) + + @Bean + fun feignDecoder(): Decoder = SpringDecoder { HttpMessageConverters() } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/config/oauth/GoogleOauthProperties.kt b/src/main/kotlin/com/devooks/backend/auth/v1/config/oauth/GoogleOauthProperties.kt new file mode 100644 index 0000000..4e8a7ab --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/config/oauth/GoogleOauthProperties.kt @@ -0,0 +1,10 @@ +package com.devooks.backend.auth.v1.config.oauth + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "google") +data class GoogleOauthProperties( + val clientId: String, + val clientSecret: String, + val redirectUri: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/config/oauth/KakaoOauthProperties.kt b/src/main/kotlin/com/devooks/backend/auth/v1/config/oauth/KakaoOauthProperties.kt new file mode 100644 index 0000000..21ff7c1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/config/oauth/KakaoOauthProperties.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.auth.v1.config.oauth + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "kakao") +data class KakaoOauthProperties( + val clientId: String, + val redirectUri: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/config/oauth/NaverOauthProperties.kt b/src/main/kotlin/com/devooks/backend/auth/v1/config/oauth/NaverOauthProperties.kt new file mode 100644 index 0000000..2e55b82 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/config/oauth/NaverOauthProperties.kt @@ -0,0 +1,10 @@ +package com.devooks.backend.auth.v1.config.oauth + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "naver") +data class NaverOauthProperties( + val clientSecret: String, + val clientId: String, + val state: String, +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/controller/AuthController.kt b/src/main/kotlin/com/devooks/backend/auth/v1/controller/AuthController.kt new file mode 100644 index 0000000..6e48fc2 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/controller/AuthController.kt @@ -0,0 +1,164 @@ +package com.devooks.backend.auth.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.domain.OauthInfo +import com.devooks.backend.auth.v1.domain.TokenGroup +import com.devooks.backend.auth.v1.dto.LoginCommand +import com.devooks.backend.auth.v1.dto.LoginRequest +import com.devooks.backend.auth.v1.dto.LoginResponse +import com.devooks.backend.auth.v1.dto.LogoutCommand +import com.devooks.backend.auth.v1.dto.LogoutRequest +import com.devooks.backend.auth.v1.dto.LogoutResponse +import com.devooks.backend.auth.v1.dto.ReissueCommand +import com.devooks.backend.auth.v1.dto.ReissueRequest +import com.devooks.backend.auth.v1.dto.ReissueResponse +import com.devooks.backend.auth.v1.service.OauthService +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.service.MemberService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import java.util.* +import org.springframework.transaction.annotation.Transactional +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 + +@Tag(name = "인증 API") +@RestController +@RequestMapping("/api/v1/auth") +class AuthController( + private val oauthService: OauthService, + private val tokenService: TokenService, + private val memberService: MemberService, +) { + + @PostMapping("/login") + @Operation(summary = "로그인") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "OK"), + ApiResponse( + responseCode = "400", + description = + "- AUTH-400-1 : 인증 코드(authorizationCode)가 NULL이거나 빈 문자일 경우\n" + + "- AUTH-400-2 : 인증 유형(oauthType)이 NAVER, KAKAO, GOOGLE 이 아닐 경우 ", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "401", + description = + "- AUTH-401-3 : 네이버 로그인을 실패할 경우\n" + + "- AUTH-401-4 : 카카오 로그인을 실패할 경우\n" + + "- AUTH-401-5 : 구글 로그인을 실패할 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "403", + description = + "- AUTH-403-1 : 정지된 회원일 경우 경우\n" + + "- AUTH-403-2 : 탈퇴한 회원일 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "404", + description = + "- MEMBER-404-1 : 회원을 찾을 수 없는 경우 (message에 oauthId를 넣어서 응답)", + content = arrayOf(Content(schema = Schema(hidden = true))) + ) + ] + ) + suspend fun login( + @RequestBody + request: LoginRequest, + ): LoginResponse { + val command: LoginCommand = request.toCommand() + val oauthInfo: OauthInfo = oauthService.getOauthInfo(command) + val member: Member = memberService.findByOauthInfo(oauthInfo) + val tokenGroup: TokenGroup = tokenService.createTokenGroup(member) + return LoginResponse(member, tokenGroup) + } + + @Transactional + @PostMapping("/logout") + @Operation(summary = "로그아웃") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "OK"), + ApiResponse( + responseCode = "400", + description = + "- AUTH-400-3 : 리프래시 토큰(refreshToken)이 NULL이거나 빈 문자일 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "401", + description = + "- AUTH-401-1 : 기간이 만료된 토큰일 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "403", + description = + "- AUTH-403-1 : 잘못된 형식의 토큰일 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ) + ] + ) + suspend fun logout( + @RequestBody + request: LogoutRequest, + ): LogoutResponse { + val command: LogoutCommand = request.toCommand() + val memberId: UUID = tokenService.getMemberId(Authorization(command.refreshToken)) + tokenService.expireRefreshToken(memberId) + return LogoutResponse() + } + + @Operation(summary = "토큰 재발급") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "OK"), + ApiResponse( + responseCode = "400", + description = + "- AUTH-400-3 : 리프래시 토큰(refreshToken)이 NULL이거나 빈 문자일 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "401", + description = + "- AUTH-401-1 : 기간이 만료된 토큰일 경우\n" + + "- AUTH-401-2 : 원본 리프래시 토큰과 일치하지 않을 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "403", + description = + "- AUTH-403-1 : 잘못된 형식의 토큰일 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "404", + description = + "- AUTH-404-1 : 리프래시 토큰이 존재하지 않을 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ) + ] + ) + @PostMapping("/reissue") + suspend fun reissue( + @RequestBody + request: ReissueRequest, + ): ReissueResponse { + val command: ReissueCommand = request.toCommand() + val tokenGroup: TokenGroup = tokenService.reissueTokenGroup(command.refreshToken) + return ReissueResponse(tokenGroup) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/domain/Authority.kt b/src/main/kotlin/com/devooks/backend/auth/v1/domain/Authority.kt new file mode 100644 index 0000000..f3ec61f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/domain/Authority.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.auth.v1.domain + +enum class Authority { + USER, ADMIN +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/domain/Authorization.kt b/src/main/kotlin/com/devooks/backend/auth/v1/domain/Authorization.kt new file mode 100644 index 0000000..c1ae606 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/domain/Authorization.kt @@ -0,0 +1,7 @@ +package com.devooks.backend.auth.v1.domain + +class Authorization( + origin: String, +) { + val token: AccessToken = origin.replace("Bearer ", "") +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/domain/OauthGrantType.kt b/src/main/kotlin/com/devooks/backend/auth/v1/domain/OauthGrantType.kt new file mode 100644 index 0000000..9b4fa53 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/domain/OauthGrantType.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.auth.v1.domain + +enum class OauthGrantType(val value: String) { + AUTHORIZATION_CODE("authorization_code") +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/domain/OauthInfo.kt b/src/main/kotlin/com/devooks/backend/auth/v1/domain/OauthInfo.kt new file mode 100644 index 0000000..021094e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/domain/OauthInfo.kt @@ -0,0 +1,8 @@ +package com.devooks.backend.auth.v1.domain + +typealias OauthId = String + +data class OauthInfo( + val oauthId: OauthId, + val oauthType: OauthType +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/domain/OauthType.kt b/src/main/kotlin/com/devooks/backend/auth/v1/domain/OauthType.kt new file mode 100644 index 0000000..c56f7af --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/domain/OauthType.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.auth.v1.domain + +enum class OauthType { + NAVER, KAKAO, GOOGLE +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/domain/TokenGroup.kt b/src/main/kotlin/com/devooks/backend/auth/v1/domain/TokenGroup.kt new file mode 100644 index 0000000..c869d74 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/domain/TokenGroup.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.auth.v1.domain + +typealias AccessToken = String +typealias RefreshToken = String + +data class TokenGroup( + val accessToken: AccessToken, + val refreshToken: RefreshToken, +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/domain/TokenSubject.kt b/src/main/kotlin/com/devooks/backend/auth/v1/domain/TokenSubject.kt new file mode 100644 index 0000000..8db11ad --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/domain/TokenSubject.kt @@ -0,0 +1,7 @@ +package com.devooks.backend.auth.v1.domain + +import java.util.* + +data class TokenSubject( + val memberId: UUID +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/dto/LoginCommand.kt b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LoginCommand.kt new file mode 100644 index 0000000..425b23e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LoginCommand.kt @@ -0,0 +1,8 @@ +package com.devooks.backend.auth.v1.dto + +import com.devooks.backend.auth.v1.domain.OauthType + +data class LoginCommand( + val authorizationCode: String, + val oauthType: OauthType, +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/dto/LoginRequest.kt b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LoginRequest.kt new file mode 100644 index 0000000..b449a31 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LoginRequest.kt @@ -0,0 +1,18 @@ +package com.devooks.backend.auth.v1.dto + +import com.devooks.backend.auth.v1.error.validateAuthorizationCode +import com.devooks.backend.auth.v1.error.validateOauthType +import io.swagger.v3.oas.annotations.media.Schema + +data class LoginRequest( + @Schema(description = "OAuth2 인증 코드", required = true, nullable = false) + val authorizationCode: String?, + @Schema(description = "OAuth2 인증 유형", required = true, nullable = false, example = "NAVER") + val oauthType: String? +) { + fun toCommand(): LoginCommand = + LoginCommand( + authorizationCode = authorizationCode.validateAuthorizationCode(), + oauthType = oauthType.validateOauthType() + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/dto/LoginResponse.kt b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LoginResponse.kt new file mode 100644 index 0000000..3c5fb83 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LoginResponse.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.auth.v1.dto + +import com.devooks.backend.auth.v1.domain.TokenGroup +import com.devooks.backend.member.v1.domain.Member + +data class LoginResponse( + val member: Member, + val tokenGroup: TokenGroup, +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/dto/LogoutCommand.kt b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LogoutCommand.kt new file mode 100644 index 0000000..bf8187d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LogoutCommand.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.auth.v1.dto + +data class LogoutCommand( + val refreshToken: String, +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/dto/LogoutRequest.kt b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LogoutRequest.kt new file mode 100644 index 0000000..20bb9f3 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LogoutRequest.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.auth.v1.dto + +import com.devooks.backend.auth.v1.error.validateRefreshToken +import io.swagger.v3.oas.annotations.media.Schema + +data class LogoutRequest( + @Schema(description = "Refresh 토큰", required = true, nullable = false) + val refreshToken: String?, +) { + fun toCommand(): LogoutCommand = + LogoutCommand( + refreshToken = refreshToken.validateRefreshToken() + ) +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/dto/LogoutResponse.kt b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LogoutResponse.kt new file mode 100644 index 0000000..6a75b5d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/dto/LogoutResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.auth.v1.dto + +data class LogoutResponse( + val message: String = "로그아웃이 완료됐습니다." +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/dto/ReissueCommand.kt b/src/main/kotlin/com/devooks/backend/auth/v1/dto/ReissueCommand.kt new file mode 100644 index 0000000..30f3b9f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/dto/ReissueCommand.kt @@ -0,0 +1,7 @@ +package com.devooks.backend.auth.v1.dto + +import com.devooks.backend.auth.v1.domain.RefreshToken + +data class ReissueCommand( + val refreshToken: RefreshToken +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/dto/ReissueRequest.kt b/src/main/kotlin/com/devooks/backend/auth/v1/dto/ReissueRequest.kt new file mode 100644 index 0000000..0d7b707 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/dto/ReissueRequest.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.auth.v1.dto + +import com.devooks.backend.auth.v1.error.validateRefreshToken + +data class ReissueRequest( + val refreshToken: String? +) { + fun toCommand(): ReissueCommand = + ReissueCommand( + refreshToken = refreshToken.validateRefreshToken() + ) +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/dto/ReissueResponse.kt b/src/main/kotlin/com/devooks/backend/auth/v1/dto/ReissueResponse.kt new file mode 100644 index 0000000..0939c34 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/dto/ReissueResponse.kt @@ -0,0 +1,7 @@ +package com.devooks.backend.auth.v1.dto + +import com.devooks.backend.auth.v1.domain.TokenGroup + +data class ReissueResponse( + val tokenGroup: TokenGroup +) diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/entity/OauthInfoEntity.kt b/src/main/kotlin/com/devooks/backend/auth/v1/entity/OauthInfoEntity.kt new file mode 100644 index 0000000..96c2f75 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/entity/OauthInfoEntity.kt @@ -0,0 +1,22 @@ +package com.devooks.backend.auth.v1.entity + +import com.devooks.backend.auth.v1.domain.OauthId +import com.devooks.backend.auth.v1.domain.OauthType +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "oauth_info") +data class OauthInfoEntity( + @Id + val oauthId: OauthId, + val oauthType: OauthType, + val memberId: UUID, + val registeredDate: Instant? = null +) : Persistable { + override fun getId(): OauthId = oauthId + + override fun isNew(): Boolean = registeredDate == null +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/entity/RefreshTokenEntity.kt b/src/main/kotlin/com/devooks/backend/auth/v1/entity/RefreshTokenEntity.kt new file mode 100644 index 0000000..efbbd77 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/entity/RefreshTokenEntity.kt @@ -0,0 +1,32 @@ +package com.devooks.backend.auth.v1.entity + +import com.devooks.backend.auth.v1.domain.RefreshToken +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "refresh_token") +data class RefreshTokenEntity( + @Id + @Column("refresh_token_id") + @get:JvmName("refreshTokenId") + val id: UUID? = null, + val memberId: UUID, + val token: RefreshToken, + val registeredDate: Instant = Instant.now(), + val modifiedDate: Instant = registeredDate, +) : Persistable { + + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun update(token: RefreshToken): RefreshTokenEntity = + this.copy( + token = token, + modifiedDate = Instant.now() + ) +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/error/AuthError.kt b/src/main/kotlin/com/devooks/backend/auth/v1/error/AuthError.kt new file mode 100644 index 0000000..0a278ef --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/error/AuthError.kt @@ -0,0 +1,44 @@ +package com.devooks.backend.auth.v1.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.CONFLICT +import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR +import org.springframework.http.HttpStatus.NOT_FOUND +import org.springframework.http.HttpStatus.UNAUTHORIZED + +enum class AuthError(val exception: GeneralException) { + // 400 + REQUIRED_AUTHORIZATION_CODE(GeneralException("AUTH-400-1", BAD_REQUEST, "인증 코드가 반드시 필요합니다.")), + INVALID_OAUTH_TYPE(GeneralException("AUTH-400-2", BAD_REQUEST, "인증 유형은 NAVER, KAKAO, GOOGLE 만 가능합니다.")), + REQUIRED_TOKEN(GeneralException("AUTH-400-3", BAD_REQUEST, "토큰이 반드시 필요합니다.")), + REQUIRED_OAUTH_ID(GeneralException("AUTH-400-4", BAD_REQUEST, "인증 식별자는 반드시 필요합니다.")), + + // 401 + EXPIRED_TOKEN(GeneralException("AUTH-401-1", UNAUTHORIZED, "만료된 토큰입니다.")), + INVALID_REFRESH_TOKEN(GeneralException("AUTH-401-2", UNAUTHORIZED, "유효하지 않는 리프래시 토큰입니다.")), + FAILED_NAVER_OAUTH_LOGIN(GeneralException("AUTH-401-3", UNAUTHORIZED, "네이버 로그인을 실패했습니다.")), + FAILED_KAKAO_OAUTH_LOGIN(GeneralException("AUTH-401-4", UNAUTHORIZED, "카카오 로그인을 실패했습니다.")), + FAILED_GOOGLE_OAUTH_LOGIN(GeneralException("AUTH-401-5", UNAUTHORIZED, "구글 로그인을 실패했습니다.")), + + // 403 + UNSUPPORTED_TOKEN_FORMAT(GeneralException("AUTH-403-1", HttpStatus.FORBIDDEN, "유효하지 않은 토큰입니다.")), + + // 404 + NOT_FOUND_REFRESH_TOKEN(GeneralException("AUTH-404-1", NOT_FOUND, "리프래시 토큰이 존재하지 않습니다.")), + + // 409 + DUPLICATE_OAUTH_ID(GeneralException("AUTH-409-1", CONFLICT, "이미 존재하는 회원입니다.")), + + // 500 + FAILED_CREATE_ACCESS_TOKEN(GeneralException("AUTH-500-1", INTERNAL_SERVER_ERROR, "액세스 토큰 생성을 실패했습니다.")), + FAILED_CREATE_REFRESH_TOKEN(GeneralException("AUTH-500-2", INTERNAL_SERVER_ERROR, "리프래시 토큰 생성을 실패했습니다.")), + FAILED_VALIDATE_TOKEN(GeneralException("AUTH-500-3", INTERNAL_SERVER_ERROR, "토큰 검증을 실패했습니다.")), + ; + + override fun toString(): String { + return "AuthException(exception=$exception)" + } + +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/error/AuthValidation.kt b/src/main/kotlin/com/devooks/backend/auth/v1/error/AuthValidation.kt new file mode 100644 index 0000000..5948232 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/error/AuthValidation.kt @@ -0,0 +1,19 @@ +package com.devooks.backend.auth.v1.error + +import com.devooks.backend.auth.v1.domain.OauthType +import com.devooks.backend.common.error.validateNotBlank + + +fun String?.validateOauthId(): String = + validateNotBlank(AuthError.REQUIRED_OAUTH_ID.exception) + +fun String?.validateOauthType(): OauthType = + validateNotBlank(AuthError.INVALID_OAUTH_TYPE.exception) + .let { runCatching { OauthType.valueOf(it) }.getOrElse { null } } + ?: throw AuthError.INVALID_OAUTH_TYPE.exception + +fun String?.validateAuthorizationCode(): String = + validateNotBlank(AuthError.REQUIRED_AUTHORIZATION_CODE.exception) + +fun String?.validateRefreshToken(): String = + validateNotBlank(AuthError.REQUIRED_TOKEN.exception) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/repository/OauthInfoRepository.kt b/src/main/kotlin/com/devooks/backend/auth/v1/repository/OauthInfoRepository.kt new file mode 100644 index 0000000..5ca7fa3 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/repository/OauthInfoRepository.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.auth.v1.repository + +import com.devooks.backend.auth.v1.domain.OauthId +import com.devooks.backend.auth.v1.domain.OauthType +import com.devooks.backend.auth.v1.entity.OauthInfoEntity +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface OauthInfoRepository : CoroutineCrudRepository { + + suspend fun findByOauthIdAndOauthType(oauthId: OauthId, oauthType: OauthType): OauthInfoEntity? +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/repository/RefreshTokenRepository.kt b/src/main/kotlin/com/devooks/backend/auth/v1/repository/RefreshTokenRepository.kt new file mode 100644 index 0000000..0ac93a5 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/repository/RefreshTokenRepository.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.auth.v1.repository + +import com.devooks.backend.auth.v1.entity.RefreshTokenEntity +import java.util.UUID +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface RefreshTokenRepository : CoroutineCrudRepository { + suspend fun findByMemberId(memberId: UUID): RefreshTokenEntity? + suspend fun deleteByMemberId(memberId: UUID) +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/service/GoogleOauthService.kt b/src/main/kotlin/com/devooks/backend/auth/v1/service/GoogleOauthService.kt new file mode 100644 index 0000000..719bc9e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/service/GoogleOauthService.kt @@ -0,0 +1,41 @@ +package com.devooks.backend.auth.v1.service + +import com.devooks.backend.auth.v1.client.google.GoogleOauthClient +import com.devooks.backend.auth.v1.client.google.GoogleProfileClient +import com.devooks.backend.auth.v1.client.google.dto.GetGoogleTokenRequest +import com.devooks.backend.auth.v1.config.oauth.GoogleOauthProperties +import com.devooks.backend.auth.v1.domain.OauthGrantType +import com.devooks.backend.auth.v1.domain.OauthId +import com.devooks.backend.auth.v1.dto.LoginCommand +import com.devooks.backend.auth.v1.error.AuthError.FAILED_GOOGLE_OAUTH_LOGIN +import org.springframework.stereotype.Service + +@Service +class GoogleOauthService( + private val googleOauthClient: GoogleOauthClient, + private val googleProfileClient: GoogleProfileClient, + private val googleOauthProperties: GoogleOauthProperties, +) { + + fun getOauthId(request: LoginCommand): OauthId = getOauthId(getToken(request)) + + private fun getOauthId(token: String): OauthId = + googleProfileClient.getOauthId(token).id + ?: throw FAILED_GOOGLE_OAUTH_LOGIN.exception + + private fun getToken(request: LoginCommand): String = + googleOauthClient + .getToken( + GetGoogleTokenRequest( + grantType = OauthGrantType.AUTHORIZATION_CODE.value, + clientId = googleOauthProperties.clientId, + clientSecret = googleOauthProperties.clientSecret, + redirectUri = googleOauthProperties.redirectUri, + code = request.authorizationCode + ).toString() + ) + .takeIf { it.accessToken != null } + ?.let { "Bearer ${it.accessToken}" } + ?: throw FAILED_GOOGLE_OAUTH_LOGIN.exception + +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/service/JwtService.kt b/src/main/kotlin/com/devooks/backend/auth/v1/service/JwtService.kt new file mode 100644 index 0000000..602f9d2 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/service/JwtService.kt @@ -0,0 +1,138 @@ +package com.devooks.backend.auth.v1.service + +import com.devooks.backend.auth.v1.config.JwtConfigProperties +import com.devooks.backend.auth.v1.domain.AccessToken +import com.devooks.backend.auth.v1.domain.RefreshToken +import com.devooks.backend.auth.v1.domain.TokenSubject +import com.devooks.backend.auth.v1.entity.RefreshTokenEntity +import com.devooks.backend.auth.v1.error.AuthError.EXPIRED_TOKEN +import com.devooks.backend.auth.v1.error.AuthError.FAILED_CREATE_ACCESS_TOKEN +import com.devooks.backend.auth.v1.error.AuthError.FAILED_CREATE_REFRESH_TOKEN +import com.devooks.backend.auth.v1.error.AuthError.FAILED_VALIDATE_TOKEN +import com.devooks.backend.auth.v1.error.AuthError.INVALID_REFRESH_TOKEN +import com.devooks.backend.auth.v1.error.AuthError.NOT_FOUND_REFRESH_TOKEN +import com.devooks.backend.auth.v1.error.AuthError.UNSUPPORTED_TOKEN_FORMAT +import com.devooks.backend.auth.v1.repository.RefreshTokenRepository +import com.devooks.backend.common.utils.logger +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.MalformedJwtException +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.UnsupportedJwtException +import io.jsonwebtoken.security.Keys +import io.jsonwebtoken.security.SignatureException +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class JwtService( + private val refreshTokenRepository: RefreshTokenRepository, + jwtConfigProperties: JwtConfigProperties, +) { + private val logger = logger() + + private val secretKey = jwtConfigProperties.secretKey.toByteArray() + private val accessTokenExpirationHour = jwtConfigProperties.accessTokenExpirationHour.toLong() + private val refreshTokenExpirationHour = jwtConfigProperties.refreshTokenExpirationHour.toLong() + + suspend fun createAccessToken(memberId: UUID): AccessToken = + runCatching { + createToken(memberId, accessTokenExpirationHour) + }.getOrElse { + logger.error(FAILED_CREATE_ACCESS_TOKEN.toString()) + logger.error(it.stackTraceToString()) + throw FAILED_CREATE_ACCESS_TOKEN.exception + } + + @Transactional + suspend fun createRefreshToken(memberId: UUID): RefreshToken = + runCatching { + val token = createToken(memberId, refreshTokenExpirationHour) + refreshTokenRepository + .findByMemberId(memberId) + .let { refreshToken -> + refreshToken + ?.update(token) + ?: RefreshTokenEntity(memberId = memberId, token = token) + } + .let { refreshToken -> refreshTokenRepository.save(refreshToken) } + token + }.getOrElse { + logger.error(FAILED_CREATE_REFRESH_TOKEN.toString()) + logger.error(it.stackTraceToString()) + throw FAILED_CREATE_REFRESH_TOKEN.exception + } + + suspend fun validateToken(token: AccessToken): TokenSubject = validateToken(secretKey, token) + + suspend fun validateRefreshToken(token: RefreshToken): TokenSubject = + runCatching { + val tokenSubject = validateToken(secretKey, token) + validateRefreshToken(tokenSubject, token) + tokenSubject + }.getOrElse { exception -> + logger.error("리프래시 토큰 검증을 실패했습니다.") + logger.error(exception.stackTraceToString()) + throw exception + } + + suspend fun expireRefreshToken(memberId: UUID) { + refreshTokenRepository.deleteByMemberId(memberId) + } + + private fun validateToken(secretKey: ByteArray, token: String): TokenSubject = + runCatching { + Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(secretKey)) + .build() + .parseClaimsJws(token) + .body + .subject + .let { memberId -> TokenSubject(UUID.fromString(memberId)) } + }.getOrElse { exception -> + val error = when (exception) { + is UnsupportedJwtException, + is MalformedJwtException, + is SignatureException, + is IllegalArgumentException, + -> UNSUPPORTED_TOKEN_FORMAT + + is ExpiredJwtException -> EXPIRED_TOKEN + + else -> FAILED_VALIDATE_TOKEN + } + logger.error(error.toString()) + logger.error(exception.stackTraceToString()) + throw error.exception + } + + private suspend fun validateRefreshToken( + tokenSubject: TokenSubject, + token: RefreshToken, + ) { + val foundRefreshToken: RefreshTokenEntity = + refreshTokenRepository + .findByMemberId(tokenSubject.memberId) + ?: throw NOT_FOUND_REFRESH_TOKEN.exception + + foundRefreshToken + .takeIf { it.token == token } + ?: throw INVALID_REFRESH_TOKEN.exception + } + + private fun createToken(memberId: UUID, expirationHour: Long): String { + val now = Instant.now() + return Jwts + .builder() + .setSubject(memberId.toString()) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(now.plus(expirationHour, ChronoUnit.HOURS))) + .signWith(Keys.hmacShaKeyFor(secretKey), SignatureAlgorithm.HS256) + .compact() + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/service/KakaoOauthService.kt b/src/main/kotlin/com/devooks/backend/auth/v1/service/KakaoOauthService.kt new file mode 100644 index 0000000..044fc2e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/service/KakaoOauthService.kt @@ -0,0 +1,39 @@ +package com.devooks.backend.auth.v1.service + +import com.devooks.backend.auth.v1.client.kakao.KakaoOauthClient +import com.devooks.backend.auth.v1.client.kakao.KakaoProfileClient +import com.devooks.backend.auth.v1.client.kakao.dto.GetKakaoTokenRequest +import com.devooks.backend.auth.v1.config.oauth.KakaoOauthProperties +import com.devooks.backend.auth.v1.domain.OauthGrantType +import com.devooks.backend.auth.v1.domain.OauthId +import com.devooks.backend.auth.v1.dto.LoginCommand +import com.devooks.backend.auth.v1.error.AuthError.FAILED_KAKAO_OAUTH_LOGIN +import org.springframework.stereotype.Service + +@Service +class KakaoOauthService( + private val kakaoOauthClient: KakaoOauthClient, + private val kakaoProfileClient: KakaoProfileClient, + private val kakaoOauthProperties: KakaoOauthProperties, +) { + + fun getOauthId(request: LoginCommand): OauthId = getOauthId(getToken(request)) + + private fun getOauthId(token: String): OauthId = + kakaoProfileClient.getOauthId(token).id?.toString() + ?: throw FAILED_KAKAO_OAUTH_LOGIN.exception + + private fun getToken(request: LoginCommand): String = + kakaoOauthClient + .getToken( + GetKakaoTokenRequest( + grantType = OauthGrantType.AUTHORIZATION_CODE.value, + clientId = kakaoOauthProperties.clientId, + redirectUri = kakaoOauthProperties.redirectUri, + code = request.authorizationCode, + ).toString() + ) + .takeIf { it.accessToken != null } + ?.let { "Bearer ${it.accessToken}" } + ?: throw FAILED_KAKAO_OAUTH_LOGIN.exception +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/service/NaverOauthService.kt b/src/main/kotlin/com/devooks/backend/auth/v1/service/NaverOauthService.kt new file mode 100644 index 0000000..9162bc7 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/service/NaverOauthService.kt @@ -0,0 +1,37 @@ +package com.devooks.backend.auth.v1.service + +import com.devooks.backend.auth.v1.client.naver.NaverOauthClient +import com.devooks.backend.auth.v1.client.naver.NaverProfileClient +import com.devooks.backend.auth.v1.config.oauth.NaverOauthProperties +import com.devooks.backend.auth.v1.domain.OauthGrantType +import com.devooks.backend.auth.v1.domain.OauthId +import com.devooks.backend.auth.v1.dto.LoginCommand +import com.devooks.backend.auth.v1.error.AuthError +import org.springframework.stereotype.Service + +@Service +class NaverOauthService( + private val naverOauthClient: NaverOauthClient, + private val naverProfileClient: NaverProfileClient, + private val naverOauthProperties: NaverOauthProperties, +) { + + fun getOauthId(command: LoginCommand): OauthId = getOauthId(getToken(command)) + + private fun getOauthId(token: String): OauthId = + naverProfileClient.getOauthId(token).profile?.id + ?: throw AuthError.FAILED_NAVER_OAUTH_LOGIN.exception + + private fun getToken(command: LoginCommand): String = + naverOauthClient + .getToken( + grantType = OauthGrantType.AUTHORIZATION_CODE.value, + clientId = naverOauthProperties.clientId, + clientSecret = naverOauthProperties.clientSecret, + code = command.authorizationCode, + state = naverOauthProperties.state + ) + .takeIf { it.token != null} + ?.token + ?: throw AuthError.FAILED_NAVER_OAUTH_LOGIN.exception +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/service/OauthService.kt b/src/main/kotlin/com/devooks/backend/auth/v1/service/OauthService.kt new file mode 100644 index 0000000..6531d4e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/service/OauthService.kt @@ -0,0 +1,31 @@ +package com.devooks.backend.auth.v1.service + +import com.devooks.backend.auth.v1.domain.OauthInfo +import com.devooks.backend.auth.v1.domain.OauthType.GOOGLE +import com.devooks.backend.auth.v1.domain.OauthType.KAKAO +import com.devooks.backend.auth.v1.domain.OauthType.NAVER +import com.devooks.backend.auth.v1.dto.LoginCommand +import com.devooks.backend.common.utils.logger +import org.springframework.stereotype.Service + +@Service +class OauthService( + private val naverOauthService: NaverOauthService, + private val kakaoOauthService: KakaoOauthService, + private val googleOauthService: GoogleOauthService, +) { + private val logger = logger() + + suspend fun getOauthInfo(command: LoginCommand): OauthInfo = + runCatching { + when (command.oauthType) { + NAVER -> OauthInfo(naverOauthService.getOauthId(command), NAVER) + KAKAO -> OauthInfo(kakaoOauthService.getOauthId(command), KAKAO) + GOOGLE -> OauthInfo(googleOauthService.getOauthId(command), GOOGLE) + } + }.getOrElse { + logger.error(it.toString()) + logger.error(it.stackTraceToString()) + throw it + } +} diff --git a/src/main/kotlin/com/devooks/backend/auth/v1/service/TokenService.kt b/src/main/kotlin/com/devooks/backend/auth/v1/service/TokenService.kt new file mode 100644 index 0000000..8b16fd1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/auth/v1/service/TokenService.kt @@ -0,0 +1,37 @@ +package com.devooks.backend.auth.v1.service + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.domain.RefreshToken +import com.devooks.backend.auth.v1.domain.TokenGroup +import com.devooks.backend.member.v1.domain.Member +import java.util.* +import org.springframework.stereotype.Service + +@Service +class TokenService( + private val jwtService: JwtService, +) { + + suspend fun createTokenGroup(member: Member): TokenGroup = + TokenGroup( + accessToken = jwtService.createAccessToken(member.id), + refreshToken = jwtService.createRefreshToken(member.id) + ) + + suspend fun reissueTokenGroup(refreshToken: RefreshToken): TokenGroup { + val tokenSubject = jwtService.validateRefreshToken(refreshToken) + val reissuedAccessToken = jwtService.createAccessToken(tokenSubject.memberId) + val reissuedRefreshToken = jwtService.createRefreshToken(tokenSubject.memberId) + return TokenGroup(reissuedAccessToken, reissuedRefreshToken) + } + + suspend fun expireRefreshToken(memberId: UUID) { + jwtService.expireRefreshToken(memberId) + } + + suspend fun getMemberId(authorization: Authorization): UUID = + jwtService + .validateToken(authorization.token) + .memberId + +} diff --git a/src/main/kotlin/com/devooks/backend/category/v1/controller/CategoryController.kt b/src/main/kotlin/com/devooks/backend/category/v1/controller/CategoryController.kt new file mode 100644 index 0000000..de7a600 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/category/v1/controller/CategoryController.kt @@ -0,0 +1,31 @@ +package com.devooks.backend.category.v1.controller + +import com.devooks.backend.category.v1.dto.GetCategoriesRequest +import com.devooks.backend.category.v1.dto.GetCategoriesResponse +import com.devooks.backend.category.v1.dto.GetCategoriesResponse.Companion.toResponse +import com.devooks.backend.category.v1.service.CategoryService +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/categories") +class CategoryController( + private val categoryService: CategoryService, +) { + + @GetMapping + suspend fun getCategories( + @RequestParam(required = false, defaultValue = "") + name: String, + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + ): GetCategoriesResponse { + val request = GetCategoriesRequest(name, page, count) + return categoryService.get(request).toResponse() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/category/v1/domain/Category.kt b/src/main/kotlin/com/devooks/backend/category/v1/domain/Category.kt new file mode 100644 index 0000000..55f3e1c --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/category/v1/domain/Category.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.category.v1.domain + +import com.devooks.backend.category.v1.entity.CategoryEntity +import java.util.* + +class Category( + val id: UUID, + val name: String, +) { + companion object { + fun CategoryEntity.toDomain() = Category(id = this.id!!, name = this.name) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/category/v1/dto/CategoryDto.kt b/src/main/kotlin/com/devooks/backend/category/v1/dto/CategoryDto.kt new file mode 100644 index 0000000..fa9b6c3 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/category/v1/dto/CategoryDto.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.category.v1.dto + +import com.devooks.backend.category.v1.domain.Category +import java.util.* + +data class CategoryDto( + val id: UUID, + val name: String, +) { + companion object { + fun Category.toDto() = + CategoryDto( + id = id, + name = name + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/category/v1/dto/GetCategoriesRequest.kt b/src/main/kotlin/com/devooks/backend/category/v1/dto/GetCategoriesRequest.kt new file mode 100644 index 0000000..a0b74c5 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/category/v1/dto/GetCategoriesRequest.kt @@ -0,0 +1,19 @@ +package com.devooks.backend.category.v1.dto + +import com.devooks.backend.common.dto.Paging + +data class GetCategoriesRequest( + val keyword: String, + val paging: Paging, +) { + + constructor( + name: String, + page: String, + count: String, + ) : this( + keyword = "%$name%", + paging = Paging(page, count), + ) + +} diff --git a/src/main/kotlin/com/devooks/backend/category/v1/dto/GetCategoriesResponse.kt b/src/main/kotlin/com/devooks/backend/category/v1/dto/GetCategoriesResponse.kt new file mode 100644 index 0000000..ed366b2 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/category/v1/dto/GetCategoriesResponse.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.category.v1.dto + +import com.devooks.backend.category.v1.domain.Category + +data class GetCategoriesResponse( + val categories: List, +) { + companion object { + fun List.toResponse() = + GetCategoriesResponse(map { CategoryDto(it.id, it.name) }) + } +} diff --git a/src/main/kotlin/com/devooks/backend/category/v1/entity/CategoryEntity.kt b/src/main/kotlin/com/devooks/backend/category/v1/entity/CategoryEntity.kt new file mode 100644 index 0000000..1944690 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/category/v1/entity/CategoryEntity.kt @@ -0,0 +1,25 @@ +package com.devooks.backend.category.v1.entity + +import java.time.Instant +import java.time.Instant.now +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "category") +data class CategoryEntity( + @Id + @Column("category_id") + @get:JvmName("categoryId") + val id: UUID? = null, + val name: String, + val registeredDate: Instant = now(), + val modifiedDate: Instant = registeredDate, + val deletedDate: Instant? = null, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null +} diff --git a/src/main/kotlin/com/devooks/backend/category/v1/repository/CategoryRepository.kt b/src/main/kotlin/com/devooks/backend/category/v1/repository/CategoryRepository.kt new file mode 100644 index 0000000..4e1ebcf --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/category/v1/repository/CategoryRepository.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.category.v1.repository + +import com.devooks.backend.category.v1.entity.CategoryEntity +import java.util.* +import kotlinx.coroutines.flow.Flow +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface CategoryRepository : CoroutineCrudRepository { + + suspend fun findByNameIsIgnoreCase(name: String): CategoryEntity? + suspend fun findAllByNameLikeIgnoreCase(name: String): Flow + suspend fun findAllByNameLikeIgnoreCase(name: String, pageable: Pageable): Flow +} diff --git a/src/main/kotlin/com/devooks/backend/category/v1/service/CategoryService.kt b/src/main/kotlin/com/devooks/backend/category/v1/service/CategoryService.kt new file mode 100644 index 0000000..dee94db --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/category/v1/service/CategoryService.kt @@ -0,0 +1,35 @@ +package com.devooks.backend.category.v1.service + +import com.devooks.backend.category.v1.domain.Category +import com.devooks.backend.category.v1.domain.Category.Companion.toDomain +import com.devooks.backend.category.v1.dto.GetCategoriesRequest +import com.devooks.backend.category.v1.entity.CategoryEntity +import com.devooks.backend.category.v1.repository.CategoryRepository +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +@Service +class CategoryService( + private val categoryRepository: CategoryRepository, +) { + + suspend fun get(request: GetCategoriesRequest): List = + categoryRepository + .findAllByNameLikeIgnoreCase( + name = request.keyword, + pageable = request.paging.value + ) + .map { Category(it.id!!, it.name) } + .toList() + + suspend fun save(categoryNames: List): List = + categoryNames + .asFlow() + .map { name -> name to categoryRepository.findByNameIsIgnoreCase(name) } + .map { (name, entity) -> entity ?: categoryRepository.save(CategoryEntity(name = name)) } + .map { it.toDomain() } + .toList() + +} diff --git a/src/main/kotlin/com/devooks/backend/common/config/converter/JsonToMapConverter.kt b/src/main/kotlin/com/devooks/backend/common/config/converter/JsonToMapConverter.kt new file mode 100644 index 0000000..d1858a1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/config/converter/JsonToMapConverter.kt @@ -0,0 +1,18 @@ +package com.devooks.backend.common.config.converter + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.r2dbc.postgresql.codec.Json +import org.springframework.core.convert.converter.Converter +import org.springframework.data.convert.ReadingConverter +import org.springframework.stereotype.Component + +@Component +@ReadingConverter +class JsonToMapConverter( + private val objectMapper: ObjectMapper +) : Converter> { + + override fun convert(source: Json): Map = + objectMapper.readValue(source.asString()) +} diff --git a/src/main/kotlin/com/devooks/backend/common/config/converter/MapToJsonConverter.kt b/src/main/kotlin/com/devooks/backend/common/config/converter/MapToJsonConverter.kt new file mode 100644 index 0000000..ba764bc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/config/converter/MapToJsonConverter.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.common.config.converter + +import com.fasterxml.jackson.databind.ObjectMapper +import io.r2dbc.postgresql.codec.Json +import org.springframework.core.convert.converter.Converter +import org.springframework.data.convert.WritingConverter +import org.springframework.stereotype.Component + +@Component +@WritingConverter +class MapToJsonConverter( + private val objectMapper: ObjectMapper +) : Converter, Json> { + + override fun convert(source: Map): Json = + Json.of(objectMapper.writeValueAsString(source)) +} diff --git a/src/main/kotlin/com/devooks/backend/common/config/cors/CorsGlobalConfig.kt b/src/main/kotlin/com/devooks/backend/common/config/cors/CorsGlobalConfig.kt new file mode 100644 index 0000000..8e84161 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/config/cors/CorsGlobalConfig.kt @@ -0,0 +1,18 @@ +package com.devooks.backend.common.config.cors + +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.config.CorsRegistry +import org.springframework.web.reactive.config.WebFluxConfigurer + +@Configuration +class CorsGlobalConfig : WebFluxConfigurer { + + override fun addCorsMappings(registry: CorsRegistry) { + registry + .addMapping("/**") + .allowedOrigins("http://localhost:3000", "http://localhost:3001") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("Authorization", "Content-Type", "X-Requested-With") + .allowCredentials(true) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/common/config/database/R2dbcConfiguration.kt b/src/main/kotlin/com/devooks/backend/common/config/database/R2dbcConfiguration.kt new file mode 100644 index 0000000..46e7522 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/config/database/R2dbcConfiguration.kt @@ -0,0 +1,48 @@ +package com.devooks.backend.common.config.database + +import com.devooks.backend.common.config.properties.DatabaseConfig +import io.r2dbc.spi.ConnectionFactories +import io.r2dbc.spi.ConnectionFactory +import io.r2dbc.spi.ConnectionFactoryOptions +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.convert.converter.Converter +import org.springframework.core.io.ClassPathResource +import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration +import org.springframework.data.r2dbc.convert.R2dbcCustomConversions +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories +import org.springframework.r2dbc.connection.init.ConnectionFactoryInitializer +import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator + +@Configuration +@EnableR2dbcRepositories +class R2dbcConfiguration( + private val databaseConfig: DatabaseConfig, + private val customConverters: List>, +) : AbstractR2dbcConfiguration() { + + override fun connectionFactory(): ConnectionFactory = + ConnectionFactories.get( + ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, databaseConfig.driver) + .option(ConnectionFactoryOptions.PROTOCOL, databaseConfig.protocol) + .option(ConnectionFactoryOptions.HOST, databaseConfig.host) + .option(ConnectionFactoryOptions.PORT, databaseConfig.port.toInt()) + .option(ConnectionFactoryOptions.DATABASE, databaseConfig.database) + .option(ConnectionFactoryOptions.USER, databaseConfig.username) + .option(ConnectionFactoryOptions.PASSWORD, databaseConfig.password) + .build() + ) + + override fun r2dbcCustomConversions(): R2dbcCustomConversions = + R2dbcCustomConversions(storeConversions, customConverters) + + @Bean + fun initializer(): ConnectionFactoryInitializer = + ConnectionFactoryInitializer() + .apply { + setConnectionFactory(connectionFactory()) + setDatabasePopulator(ResourceDatabasePopulator(ClassPathResource("schema.sql"))) + } +} diff --git a/src/main/kotlin/com/devooks/backend/common/config/properties/DatabaseConfig.kt b/src/main/kotlin/com/devooks/backend/common/config/properties/DatabaseConfig.kt new file mode 100644 index 0000000..07c8cf6 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/config/properties/DatabaseConfig.kt @@ -0,0 +1,15 @@ +package com.devooks.backend.common.config.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "spring.r2dbc") +data class DatabaseConfig( + val url: String, + val driver: String, + val protocol: String, + val host: String, + val port: String, + val database: String, + val username: String, + val password: String, +) diff --git a/src/main/kotlin/com/devooks/backend/common/domain/Image.kt b/src/main/kotlin/com/devooks/backend/common/domain/Image.kt new file mode 100644 index 0000000..c417482 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/domain/Image.kt @@ -0,0 +1,27 @@ +package com.devooks.backend.common.domain + +import com.devooks.backend.common.error.CommonError +import java.util.* + +class Image( + val base64Raw: String, + val extension: ImageExtension, + val byteSize: Long, + val order: Int +) { + + fun convertDecodedImage(): ByteArray = + runCatching { + Base64.getMimeDecoder().decode(base64Raw) + }.getOrElse { + throw CommonError.FAIL_SAVE_IMAGE.exception + } + + companion object { + private const val MAX_BYTE_SIZE = 50_000_000 + + fun Long?.validateByteSize(): Long = + this?.takeIf { it <= MAX_BYTE_SIZE } + ?: throw CommonError.INVALID_BYTE_SIZE.exception + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/common/domain/ImageExtension.kt b/src/main/kotlin/com/devooks/backend/common/domain/ImageExtension.kt new file mode 100644 index 0000000..4229e38 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/domain/ImageExtension.kt @@ -0,0 +1,22 @@ +package com.devooks.backend.common.domain + +import com.devooks.backend.common.error.CommonError +import java.util.* + +enum class ImageExtension { + JPG, PNG, JPEG; + + override fun toString(): String = + this.name.lowercase(Locale.getDefault()) + + companion object { + fun String?.validateImageExtension(): ImageExtension = + runCatching { + this?.takeIf { it.isNotBlank() } + ?.let { ImageExtension.valueOf(it.uppercase()) } + ?: throw CommonError.INVALID_IMAGE_EXTENSION.exception + }.getOrElse { + throw CommonError.INVALID_IMAGE_EXTENSION.exception + } + } +} diff --git a/src/main/kotlin/com/devooks/backend/common/dto/ImageDto.kt b/src/main/kotlin/com/devooks/backend/common/dto/ImageDto.kt new file mode 100644 index 0000000..f1be90a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/dto/ImageDto.kt @@ -0,0 +1,23 @@ +package com.devooks.backend.common.dto + +import com.devooks.backend.common.domain.Image +import com.devooks.backend.common.domain.Image.Companion.validateByteSize +import com.devooks.backend.common.domain.ImageExtension.Companion.validateImageExtension +import com.devooks.backend.common.error.CommonError +import com.devooks.backend.common.error.validateImageOrder +import com.devooks.backend.common.error.validateNotBlank + +data class ImageDto( + val base64Raw: String?, + val extension: String?, + val byteSize: Long?, + val order: Int? +) { + fun toDomain(): Image = + Image( + base64Raw = base64Raw.validateNotBlank(CommonError.REQUIRED_BASE64RAW.exception), + extension = extension.validateImageExtension(), + byteSize = byteSize.validateByteSize(), + order = order.validateImageOrder() + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/common/dto/PageResponse.kt b/src/main/kotlin/com/devooks/backend/common/dto/PageResponse.kt new file mode 100644 index 0000000..20590ea --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/dto/PageResponse.kt @@ -0,0 +1,30 @@ +package com.devooks.backend.common.dto + +import com.devooks.backend.common.dto.PageResponse.PageableResponse.Companion.toPageable +import org.springframework.data.domain.Page + +data class PageResponse( + val data: List, + val pageable: PageableResponse, +) { + companion object { + fun Page.toResponse() = + PageResponse( + data = this.content, + pageable = this.toPageable() + ) + } + + data class PageableResponse( + val totalPages: Int, + val totalElements: Long, + ) { + companion object { + fun Page<*>.toPageable() = + PageableResponse( + totalPages = this.totalPages, + totalElements = this.totalElements, + ) + } + } +} diff --git a/src/main/kotlin/com/devooks/backend/common/dto/Paging.kt b/src/main/kotlin/com/devooks/backend/common/dto/Paging.kt new file mode 100644 index 0000000..6f3b328 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/dto/Paging.kt @@ -0,0 +1,35 @@ +package com.devooks.backend.common.dto + +import com.devooks.backend.common.error.CommonError +import org.springframework.data.domain.Pageable + +private typealias Count = String +private typealias Page = String + +data class Paging( + val value: Pageable, +) { + constructor( + page: Page, + count: Count, + ) : this( + value = Pageable + .ofSize(count.toIntCount()) + .withPage(page.toIntPage() - 1) + ) + + val offset: Int = value.offset.toInt() + val limit: Int = value.pageSize * (value.pageNumber + 1) +} + +private fun Page.toIntPage() = + runCatching { + this.toInt().takeIf { it > 0 } + ?: throw CommonError.INVALID_PAGE.exception + }.getOrElse { throw CommonError.INVALID_PAGE.exception } + +private fun Count.toIntCount() = + runCatching { + this.toInt().takeIf { it in 1..1000 } + ?: throw CommonError.INVALID_COUNT.exception + }.getOrElse { throw CommonError.INVALID_COUNT.exception } diff --git a/src/main/kotlin/com/devooks/backend/common/error/CommonError.kt b/src/main/kotlin/com/devooks/backend/common/error/CommonError.kt new file mode 100644 index 0000000..c635c67 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/error/CommonError.kt @@ -0,0 +1,26 @@ +package com.devooks.backend.common.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR + +enum class CommonError(val exception: GeneralException) { + // 400 + INVALID_PAGE(GeneralException("COMMON-400-1", BAD_REQUEST, "페이지는 1부터 조회할 수 있습니다.")), + INVALID_COUNT(GeneralException("COMMON-400-2", BAD_REQUEST, "개수는 1~1000 까지 조회할 수 있습니다.")), + REQUIRED_BASE64RAW(GeneralException("COMMON-400-3", BAD_REQUEST, "이미지 내용이 반드시 필요합니다.")), + INVALID_IMAGE_EXTENSION(GeneralException("COMMON-400-4", BAD_REQUEST, "유효하지 않은 이미지 확장자입니다.")), + INVALID_BYTE_SIZE(GeneralException("COMMON-400-5", BAD_REQUEST, "50MB 이하의 영상만 저장이 가능합니다.")), + REQUIRED_IMAGE(GeneralException("COMMON-400-6", BAD_REQUEST, "이미지가 반드시 필요합니다.")), + INVALID_IMAGE_ORDER(GeneralException("COMMON-400-7", BAD_REQUEST, "유효하지 않은 이미지 순서입니다.")), + + // 500 + FAIL_SAVE_IMAGE(GeneralException("COMMON-500-1", INTERNAL_SERVER_ERROR, "이미지 저장을 실패했습니다.")), + FAIL_SAVE_FILE(GeneralException("COMMON-500-2", INTERNAL_SERVER_ERROR, "파일 저장을 실패했습니다.")), + FAIL_CREATE_DIRECTORY(GeneralException("COMMON-500-3", INTERNAL_SERVER_ERROR, "디렉터리 저장을 실패했습니다.")) + ; + + override fun toString(): String { + return "CommonError(exception=$exception)" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/common/error/CommonValidation.kt b/src/main/kotlin/com/devooks/backend/common/error/CommonValidation.kt new file mode 100644 index 0000000..c052eb7 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/error/CommonValidation.kt @@ -0,0 +1,27 @@ +package com.devooks.backend.common.error + +import com.devooks.backend.common.domain.Image +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.common.exception.GeneralException +import java.util.* + +inline fun T?.validateNotNull(exception: GeneralException): T = + takeIf { it != null } ?: throw exception + +fun String?.validateNotBlank(exception: GeneralException): String = + takeIf { it.isNullOrBlank().not() } ?: throw exception + +fun List?.validateNotEmpty(exception: GeneralException): List = + takeIf { !isNullOrEmpty() } ?: throw exception + +fun ImageDto?.validateImage(): Image = + this?.toDomain() ?: throw CommonError.REQUIRED_IMAGE.exception + +fun List?.validateImages(): List = + takeIf { it.isNullOrEmpty().not() }?.map { it.toDomain() } ?: throw CommonError.REQUIRED_IMAGE.exception + +fun Int?.validateImageOrder(): Int = + takeIf { it != null && it >= 0 } ?: throw CommonError.INVALID_IMAGE_ORDER.exception + +fun String?.validateUUID(exception: GeneralException): UUID = + runCatching { UUID.fromString(this) }.getOrElse { throw exception } \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/common/exception/GeneralException.kt b/src/main/kotlin/com/devooks/backend/common/exception/GeneralException.kt new file mode 100644 index 0000000..fdd48d8 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/exception/GeneralException.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.common.exception + +import org.springframework.http.HttpStatus + +data class GeneralException( + val code: String, + val status: HttpStatus, + override val message: String, +) : RuntimeException(message) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/common/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/devooks/backend/common/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..38fc198 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/exception/GlobalExceptionHandler.kt @@ -0,0 +1,105 @@ +package com.devooks.backend.common.exception + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.springframework.boot.autoconfigure.web.WebProperties +import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler +import org.springframework.boot.web.error.ErrorAttributeOptions +import org.springframework.boot.web.reactive.error.DefaultErrorAttributes +import org.springframework.boot.web.reactive.error.ErrorAttributes +import org.springframework.context.ApplicationContext +import org.springframework.core.annotation.Order +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.codec.ServerCodecConfigurer +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebExchangeBindException +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.server.RequestPredicates +import org.springframework.web.reactive.function.server.RouterFunction +import org.springframework.web.reactive.function.server.RouterFunctions +import org.springframework.web.reactive.function.server.ServerRequest +import org.springframework.web.reactive.function.server.ServerResponse +import org.springframework.web.reactive.resource.NoResourceFoundException +import org.springframework.web.server.MissingRequestValueException +import org.springframework.web.server.ServerWebInputException +import reactor.core.publisher.Mono + +@Component +@Order(-2) +class GlobalErrorWebExceptionHandler( + globalErrorAttributes: DefaultErrorAttributes, + applicationContext: ApplicationContext, + serverCodecConfigurer: ServerCodecConfigurer, + val objectMapper: ObjectMapper +) : AbstractErrorWebExceptionHandler(globalErrorAttributes, WebProperties.Resources(), applicationContext) { + + init { + super.setMessageReaders(serverCodecConfigurer.readers) + super.setMessageWriters(serverCodecConfigurer.writers) + } + + override fun getRoutingFunction(errorAttributes: ErrorAttributes?): RouterFunction { + return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse) + } + + private fun renderErrorResponse(request: ServerRequest): Mono { + val errorAttributes = getErrorAttributes(request, ErrorAttributeOptions.defaults()) + + return when (val error = getError(request)) { + is WebExchangeBindException -> { + errorAttributes["errors"] = error.bindingResult.allErrors.map { "${it.code} : ${it.defaultMessage}" } + ServerResponse.status(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(errorAttributes)) + } + + is GeneralException -> { + errorAttributes["code"] = error.code + errorAttributes["message"] = + if (error.code == "MEMBER-404-1") { + objectMapper.readValue>(error.message) + } else { + error.message + } + errorAttributes["status"] = error.status.value() + errorAttributes["error"] = error.status.name + ServerResponse + .status(error.status) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(errorAttributes)) + } + + is MissingRequestValueException -> { + errorAttributes["reason"] = error.reason + ServerResponse + .status(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(errorAttributes)) + } + + is ServerWebInputException -> { + errorAttributes["reason"] = error.cause.toString() + ServerResponse + .status(error.statusCode) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(errorAttributes)) + } + + is NoResourceFoundException -> { + errorAttributes["reason"] = "존재하지 않는 API 입니다." + ServerResponse + .status(HttpStatus.NOT_FOUND) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(errorAttributes)) + } + + else -> { + ServerResponse + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(errorAttributes)) + } + } + } +} diff --git a/src/main/kotlin/com/devooks/backend/common/utils/FileUtils.kt b/src/main/kotlin/com/devooks/backend/common/utils/FileUtils.kt new file mode 100644 index 0000000..0155d95 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/utils/FileUtils.kt @@ -0,0 +1,58 @@ +package com.devooks.backend.common.utils + +import com.devooks.backend.common.domain.Image +import com.devooks.backend.common.error.CommonError +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.pathString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async + +suspend inline fun saveImage(image: Image, rootPath: String): String = + saveFileOrNull( + extension = image.extension.toString(), + rootPath = rootPath, + content = image.convertDecodedImage() + ).await() + ?.path + ?: throw CommonError.FAIL_SAVE_FILE.exception + +fun saveFileOrNull( + extension: String, + rootPath: String, + content: ByteArray, +): Deferred = + CoroutineScope(Dispatchers.IO) + .async { + val fileName = UUID.randomUUID().toString() + val targetLocation = Path.of(rootPath, "$fileName.$extension") + runCatching { + Files.write(targetLocation, content).toFile() + }.onFailure { + val logger = logger() + logger.error("파일 저장을 실패했습니다 [ targetLocation : ${targetLocation.pathString} ]") + logger.error(it.stackTraceToString()) + }.getOrNull() + } + +inline fun T.createDirectory(path: String) { + val logger = logger() + val directory = File(Path.of(path).toUri()) + runCatching { + if (directory.canRead().not()) { + directory.mkdir() + logger.info("디렉토리 생성을 완료했습니다 [ path : ${directory.absolutePath} ]") + } else { + logger.info("디렉토리가 이미 존재합니다 [ path : ${directory.absolutePath} ]") + } + }.onFailure { + val message = "디렉토리 생성을 실패했습니다 [ path : ${directory.absolutePath} ]" + logger.error(message) + logger.error(it.stackTraceToString()) + throw CommonError.FAIL_CREATE_DIRECTORY.exception + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/common/utils/LoggingUtil.kt b/src/main/kotlin/com/devooks/backend/common/utils/LoggingUtil.kt new file mode 100644 index 0000000..c3e7bcf --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/common/utils/LoggingUtil.kt @@ -0,0 +1,6 @@ +package com.devooks.backend.common.utils + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +inline fun T.logger(): Logger = LoggerFactory.getLogger(T::class.java) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookController.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookController.kt new file mode 100644 index 0000000..fb38bf0 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookController.kt @@ -0,0 +1,156 @@ +package com.devooks.backend.ebook.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.category.v1.domain.Category +import com.devooks.backend.category.v1.service.CategoryService +import com.devooks.backend.ebook.v1.domain.Ebook +import com.devooks.backend.ebook.v1.domain.EbookImage +import com.devooks.backend.ebook.v1.dto.command.CreateEbookCommand +import com.devooks.backend.ebook.v1.dto.command.DeleteEbookCommand +import com.devooks.backend.ebook.v1.dto.command.GetDetailOfEbookCommand +import com.devooks.backend.ebook.v1.dto.command.GetEbookCommand +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookCommand +import com.devooks.backend.ebook.v1.dto.request.CreateEbookRequest +import com.devooks.backend.ebook.v1.dto.request.ModifyEbookRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookResponse +import com.devooks.backend.ebook.v1.dto.response.DeleteEbookResponse +import com.devooks.backend.ebook.v1.dto.response.EbookResponse +import com.devooks.backend.ebook.v1.dto.response.GetDetailOfEbookResponse +import com.devooks.backend.ebook.v1.dto.response.GetDetailOfEbookResponse.Companion.toGetDetailOfEbookResponse +import com.devooks.backend.ebook.v1.dto.response.GetEbooksResponse +import com.devooks.backend.ebook.v1.dto.response.GetEbooksResponse.Companion.toGetEbooksResponse +import com.devooks.backend.ebook.v1.dto.response.ModifyEbookResponse +import com.devooks.backend.ebook.v1.service.EbookImageService +import com.devooks.backend.ebook.v1.service.EbookService +import com.devooks.backend.ebook.v1.service.RelatedCategoryService +import com.devooks.backend.pdf.v1.service.PdfService +import java.util.* +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/ebooks") +class EbookController( + private val ebookService: EbookService, + private val pdfService: PdfService, + private val ebookImageService: EbookImageService, + private val categoryService: CategoryService, + private val relatedCategoryService: RelatedCategoryService, + private val tokenService: TokenService, +) { + + @Transactional + @PostMapping + suspend fun createEbook( + @RequestBody + request: CreateEbookRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): CreateEbookResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command: CreateEbookCommand = request.toCommand(requesterId) + pdfService.validate(command) + ebookImageService.validate(command) + val ebook: Ebook = ebookService.create(command) + ebookImageService.save(listOf(command.mainImageId), ebook) + val descriptionImageList: List = + ebookImageService.save(command.descriptionImageIdList, ebook) + val categoryList: List = categoryService.save(command.relatedCategoryNameList) + relatedCategoryService.save(categoryList, ebook) + return CreateEbookResponse(EbookResponse(ebook, descriptionImageList, categoryList)) + } + + @GetMapping + suspend fun getEbooks( + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + @RequestParam(required = false, defaultValue = "") + title: String, + @RequestParam(required = false, defaultValue = "") + sellingMemberId: String, + @RequestParam(required = false, defaultValue = "") + ebookIdList: List, + @RequestParam(required = false, defaultValue = "") + categoryIdList: List, + @RequestParam(required = false, defaultValue = "") + orderBy: String, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): GetEbooksResponse { + val requesterId = authorization + .takeIf { it.isNotBlank() } + ?.let { tokenService.getMemberId(Authorization(it)) } + val command = GetEbookCommand( + title = title, + sellingMemberId = sellingMemberId, + ebookIdList = ebookIdList, + categoryIdList = categoryIdList, + orderBy = orderBy, + requesterId = requesterId, + page = page, + count = count + ) + return ebookService.get(command).toGetEbooksResponse() + } + + @GetMapping("/{ebookId}") + suspend fun getDetailOfEbook( + @PathVariable("ebookId", required = false) + ebookId: String, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): GetDetailOfEbookResponse { + val requesterId = authorization + .takeIf { it.isNotBlank() } + ?.let { tokenService.getMemberId(Authorization(it)) } + val command = GetDetailOfEbookCommand(ebookId, requesterId) + return ebookService.get(command).toGetDetailOfEbookResponse() + } + + @Transactional + @PatchMapping("/{ebookId}") + suspend fun modifyEbook( + @PathVariable("ebookId", required = false) + ebookId: String, + @RequestBody + request: ModifyEbookRequest, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): ModifyEbookResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyEbookCommand = request.toCommand(ebookId, requesterId) + ebookImageService.modifyMainImage(command) + val ebook: Ebook = ebookService.modify(command) + val descriptionImageList: List = ebookImageService.modifyDescriptionImageList(command, ebook) + val categoryList: List = relatedCategoryService.modify(command, ebook) + return ModifyEbookResponse(EbookResponse(ebook, descriptionImageList, categoryList)) + } + + @Transactional + @DeleteMapping("/{ebookId}") + suspend fun deleteEbook( + @PathVariable("ebookId", required = false) + ebookId: String, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): DeleteEbookResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command = DeleteEbookCommand(ebookId, requesterId) + ebookService.delete(command) + return DeleteEbookResponse() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookImageController.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookImageController.kt new file mode 100644 index 0000000..0f02aa6 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookImageController.kt @@ -0,0 +1,56 @@ +package com.devooks.backend.ebook.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.ebook.v1.domain.EbookImage +import com.devooks.backend.ebook.v1.dto.command.SaveDescriptionImagesCommand +import com.devooks.backend.ebook.v1.dto.command.SaveMainImageCommand +import com.devooks.backend.ebook.v1.dto.request.SaveDescriptionImagesRequest +import com.devooks.backend.ebook.v1.dto.request.SaveMainImageRequest +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse.Companion.toSaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse.Companion.toSaveMainImageResponse +import com.devooks.backend.ebook.v1.service.EbookImageService +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/ebooks") +class EbookImageController( + private val ebookImageService: EbookImageService, + private val tokenService: TokenService, +) { + @Transactional + @PostMapping("/description-images") + suspend fun saveDescriptionImages( + @RequestBody + request: SaveDescriptionImagesRequest, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): SaveDescriptionImagesResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: SaveDescriptionImagesCommand = request.toCommand(requesterId) + val descriptionImageList: List = ebookImageService.save(command.imageList, requesterId) + return descriptionImageList.toSaveDescriptionImagesResponse() + } + + @Transactional + @PostMapping("/main-image") + suspend fun saveMainImage( + @RequestBody + request: SaveMainImageRequest, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): SaveMainImageResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: SaveMainImageCommand = request.toCommand(requesterId) + val mainImage: EbookImage = ebookImageService.save(listOf(command.image), requesterId).first() + return mainImage.toSaveMainImageResponse() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryCommentController.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryCommentController.kt new file mode 100644 index 0000000..4fda1f9 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryCommentController.kt @@ -0,0 +1,103 @@ +package com.devooks.backend.ebook.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.ebook.v1.domain.EbookInquiryComment +import com.devooks.backend.ebook.v1.dto.command.CreateEbookInquiryCommentCommand +import com.devooks.backend.ebook.v1.dto.command.DeleteEbookInquiryCommentCommand +import com.devooks.backend.ebook.v1.dto.command.GetEbookInquireCommentsCommand +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookInquiryCommentCommand +import com.devooks.backend.ebook.v1.dto.request.CreateEbookInquiryCommentRequest +import com.devooks.backend.ebook.v1.dto.request.ModifyEbookInquiryCommentRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookInquiryCommentResponse +import com.devooks.backend.ebook.v1.dto.response.CreateEbookInquiryCommentResponse.Companion.toCreateEbookInquiryCommentResponse +import com.devooks.backend.ebook.v1.dto.response.DeleteEbookInquiryCommentResponse +import com.devooks.backend.ebook.v1.dto.response.GetEbookInquiryCommentsResponse +import com.devooks.backend.ebook.v1.dto.response.GetEbookInquiryCommentsResponse.Companion.toGetEbookInquiryCommentsResponse +import com.devooks.backend.ebook.v1.dto.response.ModifyEbookInquiryCommentResponse +import com.devooks.backend.ebook.v1.dto.response.ModifyEbookInquiryCommentResponse.Companion.toModifyEbookInquiryCommentResponse +import com.devooks.backend.ebook.v1.service.EbookInquiryCommentEventService +import com.devooks.backend.ebook.v1.service.EbookInquiryCommentService +import com.devooks.backend.ebook.v1.service.EbookInquiryService +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/ebook-inquiry-comments") +class EbookInquiryCommentController( + private val ebookInquiryService: EbookInquiryService, + private val tokenService: TokenService, + private val ebookInquiryCommentService: EbookInquiryCommentService, + private val ebookInquiryCommentEventService: EbookInquiryCommentEventService, +) { + + @Transactional + @PostMapping + suspend fun createEbookInquiryComment( + @RequestBody + request: CreateEbookInquiryCommentRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): CreateEbookInquiryCommentResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: CreateEbookInquiryCommentCommand = request.toCommand(requesterId) + ebookInquiryService.validate(command) + val inquiryComment: EbookInquiryComment = ebookInquiryCommentService.create(command) + ebookInquiryCommentEventService.publish(inquiryComment) + return inquiryComment.toCreateEbookInquiryCommentResponse() + } + + @GetMapping + suspend fun getEbookInquiryComments( + @RequestParam(required = false, defaultValue = "") + inquiryId: String, + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + ): GetEbookInquiryCommentsResponse { + val command = GetEbookInquireCommentsCommand(inquiryId, page, count) + val inquiryCommentList: List = ebookInquiryCommentService.get(command) + return inquiryCommentList.toGetEbookInquiryCommentsResponse() + } + + @Transactional + @PatchMapping("/{commentId}") + suspend fun modifyEbookInquiryComment( + @PathVariable(name = "commentId", required = false) + commentId: String, + @RequestBody + request: ModifyEbookInquiryCommentRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): ModifyEbookInquiryCommentResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyEbookInquiryCommentCommand = request.toCommand(commentId, requesterId) + val comment: EbookInquiryComment = ebookInquiryCommentService.modify(command) + return comment.toModifyEbookInquiryCommentResponse() + } + + @Transactional + @DeleteMapping("/{commentId}") + suspend fun deleteEbookInquiryComment( + @PathVariable(name = "commentId", required = false) + commentId: String, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): DeleteEbookInquiryCommentResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command = DeleteEbookInquiryCommentCommand(commentId, requesterId) + ebookInquiryCommentService.delete(command) + return DeleteEbookInquiryCommentResponse() + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryController.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryController.kt new file mode 100644 index 0000000..14e9219 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryController.kt @@ -0,0 +1,104 @@ +package com.devooks.backend.ebook.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.ebook.v1.domain.EbookInquiry +import com.devooks.backend.ebook.v1.dto.command.CreateEbookInquiryCommand +import com.devooks.backend.ebook.v1.dto.command.DeleteEbookInquiryCommand +import com.devooks.backend.ebook.v1.dto.command.GetEbookInquiresCommand +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookInquiryCommand +import com.devooks.backend.ebook.v1.dto.request.CreateEbookInquiryRequest +import com.devooks.backend.ebook.v1.dto.request.ModifyEbookInquiryRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookInquiryResponse +import com.devooks.backend.ebook.v1.dto.response.CreateEbookInquiryResponse.Companion.toCreateEbookInquiryResponse +import com.devooks.backend.ebook.v1.dto.response.DeleteEbookInquiryResponse +import com.devooks.backend.ebook.v1.dto.response.GetEbookInquiriesResponse +import com.devooks.backend.ebook.v1.dto.response.GetEbookInquiriesResponse.Companion.toGetEbookInquiriesResponse +import com.devooks.backend.ebook.v1.dto.response.ModifyEbookInquiryResponse +import com.devooks.backend.ebook.v1.dto.response.ModifyEbookInquiryResponse.Companion.toModifyEbookInquiryResponse +import com.devooks.backend.ebook.v1.service.EbookInquiryEventService +import com.devooks.backend.ebook.v1.service.EbookInquiryService +import com.devooks.backend.ebook.v1.service.EbookService +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/ebook-inquiries") +class EbookInquiryController( + private val ebookService: EbookService, + private val tokenService: TokenService, + private val ebookInquiryService: EbookInquiryService, + private val ebookInquiryEventService: EbookInquiryEventService, +) { + + @Transactional + @PostMapping + suspend fun createEbookInquiry( + @RequestBody + request: CreateEbookInquiryRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): CreateEbookInquiryResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: CreateEbookInquiryCommand = request.toCommand(requesterId) + ebookService.validate(command) + val inquiry: EbookInquiry = ebookInquiryService.create(command) + ebookInquiryEventService.publish(inquiry) + return inquiry.toCreateEbookInquiryResponse() + } + + @GetMapping + suspend fun getEbookInquiries( + @RequestParam(required = false, defaultValue = "") + ebookId: String, + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + ): GetEbookInquiriesResponse { + val command = GetEbookInquiresCommand(ebookId, page, count) + val ebookInquiryList: List = ebookInquiryService.get(command) + return ebookInquiryList.toGetEbookInquiriesResponse() + } + + @Transactional + @PatchMapping("/{inquiryId}") + suspend fun modifyEbookInquiry( + @PathVariable(name = "inquiryId", required = false) + inquiryId: String, + @RequestBody + request: ModifyEbookInquiryRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): ModifyEbookInquiryResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyEbookInquiryCommand = request.toCommand(inquiryId, requesterId) + val ebookInquiry: EbookInquiry = ebookInquiryService.modify(command) + return ebookInquiry.toModifyEbookInquiryResponse() + } + + @Transactional + @DeleteMapping("/{inquiryId}") + suspend fun deleteEbookInquiry( + @PathVariable(name = "inquiryId", required = false) + inquiryId: String, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): DeleteEbookInquiryResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command = DeleteEbookInquiryCommand(inquiryId, requesterId) + ebookInquiryService.delete(command) + return DeleteEbookInquiryResponse() + } + +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/domain/Ebook.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/Ebook.kt new file mode 100644 index 0000000..38a2026 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/Ebook.kt @@ -0,0 +1,30 @@ +package com.devooks.backend.ebook.v1.domain + +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookCommand +import java.time.Instant +import java.util.* + +data class Ebook( + val id: UUID, + val pdfId: UUID, + val mainImageId: UUID, + val title: String, + val price: Int, + val tableOfContents: String, + val introduction: String, + val createdDate: Instant, + val modifiedDate: Instant, + val deletedDate: Instant?, + val sellingMemberId: UUID, +) { + fun modify(command: ModifyEbookCommand): Ebook { + return copy( + title = command.title ?: this.title, + mainImageId = command.mainImageId ?: this.mainImageId, + introduction = command.introduction ?: this.introduction, + tableOfContents = command.tableOfContents ?: this.tableOfContents, + price = command.price ?: this.price, + modifiedDate = Instant.now() + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookImage.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookImage.kt new file mode 100644 index 0000000..4b60c44 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookImage.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.ebook.v1.domain + +import java.nio.file.Path +import java.util.* + +class EbookImage( + val id: UUID, + val imagePath: Path, + val order: Int, + val uploadMemberId: UUID, + val ebookId: UUID?, +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookInquiry.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookInquiry.kt new file mode 100644 index 0000000..7fbe27b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookInquiry.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.ebook.v1.domain + +import java.time.Instant +import java.util.* + +class EbookInquiry( + val id: UUID, + val content: String, + val ebookId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant, + val modifiedDate: Instant, +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookInquiryComment.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookInquiryComment.kt new file mode 100644 index 0000000..c294515 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookInquiryComment.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.ebook.v1.domain + +import java.time.Instant +import java.util.* + +class EbookInquiryComment( + val id: UUID, + val inquiryId: UUID, + val content: String, + val writerMemberId: UUID, + val writtenDate: Instant, + val modifiedDate: Instant, +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookOrder.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookOrder.kt new file mode 100644 index 0000000..68d0d0e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/EbookOrder.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.ebook.v1.domain + +import com.devooks.backend.ebook.v1.error.EbookError + +enum class EbookOrder { + LATEST, REVIEW; + + companion object { + fun String.toEbookOrder(): EbookOrder = + runCatching { + EbookOrder.valueOf(this) + }.getOrElse { + throw EbookError.INVALID_EBOOK_ORDER.exception + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/domain/Top100.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/Top100.kt new file mode 100644 index 0000000..15556b4 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/domain/Top100.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.ebook.v1.domain + +import com.devooks.backend.ebook.v1.error.EbookError + +enum class Top100 { + DAILY, WEEKLY, MONTHLY; + + companion object { + fun String.toTop100(): Top100 = + runCatching { + Top100.valueOf(this) + }.getOrElse { + throw EbookError.INVALID_TOP_100.exception + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/DescriptionImageDto.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/DescriptionImageDto.kt new file mode 100644 index 0000000..c7bafe6 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/DescriptionImageDto.kt @@ -0,0 +1,20 @@ +package com.devooks.backend.ebook.v1.dto + +import com.devooks.backend.ebook.v1.domain.EbookImage +import java.util.* +import kotlin.io.path.pathString + +data class DescriptionImageDto( + val id: UUID, + val imagePath: String, + val order: Int, +) { + companion object { + fun EbookImage.toDto() = + DescriptionImageDto( + id = id, + imagePath = imagePath.pathString, + order = order + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookDetailView.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookDetailView.kt new file mode 100644 index 0000000..e4613aa --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookDetailView.kt @@ -0,0 +1,22 @@ +package com.devooks.backend.ebook.v1.dto + +import java.time.Instant +import java.util.* + +data class EbookDetailView( + val id: UUID, + val mainImagePath: String, + val descriptionImagePathList: List?, + val wishlistId: UUID?, + val title: String, + val review: ReviewView, + val relatedCategoryNameList: List, + val sellingMemberId: UUID, + val createdDate: Instant, + val modifiedDate: Instant, + val pageCount: Int, + val price: Int, + val pdfId: UUID, + val introduction: String, + val tableOfContents: String, +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookInquiryCommentDto.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookInquiryCommentDto.kt new file mode 100644 index 0000000..8babdc7 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookInquiryCommentDto.kt @@ -0,0 +1,26 @@ +package com.devooks.backend.ebook.v1.dto + +import com.devooks.backend.ebook.v1.domain.EbookInquiryComment +import java.time.Instant +import java.util.* + +data class EbookInquiryCommentDto( + val id: UUID, + val content: String, + val inquiryId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant, + val modifiedDate: Instant, +) { + companion object { + fun EbookInquiryComment.toDto() = + EbookInquiryCommentDto( + id = this.id, + content = this.content, + inquiryId = this.inquiryId, + writerMemberId = this.writerMemberId, + writtenDate = this.writtenDate, + modifiedDate = this.modifiedDate, + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookInquiryDto.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookInquiryDto.kt new file mode 100644 index 0000000..6c832d1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookInquiryDto.kt @@ -0,0 +1,26 @@ +package com.devooks.backend.ebook.v1.dto + +import com.devooks.backend.ebook.v1.domain.EbookInquiry +import java.time.Instant +import java.util.* + +data class EbookInquiryDto( + val id: UUID, + val content: String, + val ebookId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant, + val modifiedDate: Instant, +) { + companion object { + fun EbookInquiry.toDto() = + EbookInquiryDto( + id = this.id, + content = this.content, + ebookId = this.ebookId, + writerMemberId = this.writerMemberId, + writtenDate = this.writtenDate, + modifiedDate = this.modifiedDate, + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookView.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookView.kt new file mode 100644 index 0000000..0b179fc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/EbookView.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.ebook.v1.dto + +import java.util.* + +data class EbookView( + val id: UUID, + val mainImagePath: String, + val title: String, + val wishlistId: UUID?, + val review: ReviewView, + val relatedCategoryNameList: List +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/ReviewView.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/ReviewView.kt new file mode 100644 index 0000000..944984f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/ReviewView.kt @@ -0,0 +1,6 @@ +package com.devooks.backend.ebook.v1.dto + +data class ReviewView( + val rating: Double, + val count: Int +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/CreateEbookCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/CreateEbookCommand.kt new file mode 100644 index 0000000..052fc21 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/CreateEbookCommand.kt @@ -0,0 +1,15 @@ +package com.devooks.backend.ebook.v1.dto.command + +import java.util.* + +class CreateEbookCommand( + val pdfId: UUID, + val title: String, + val relatedCategoryNameList: List, + val mainImageId: UUID, + val descriptionImageIdList: List, + val price: Int, + val introduction: String, + val tableOfContents: String, + val sellingMemberId: UUID +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/CreateEbookInquiryCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/CreateEbookInquiryCommand.kt new file mode 100644 index 0000000..d874caa --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/CreateEbookInquiryCommand.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.ebook.v1.dto.command + +import java.util.* + +class CreateEbookInquiryCommand( + val ebookId: UUID, + val content: String, + val requesterId: UUID +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/CreateEbookInquiryCommentCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/CreateEbookInquiryCommentCommand.kt new file mode 100644 index 0000000..755f6b1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/CreateEbookInquiryCommentCommand.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.ebook.v1.dto.command + +import java.util.* + +class CreateEbookInquiryCommentCommand( + val inquiryId: UUID, + val content: String, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/DeleteEbookCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/DeleteEbookCommand.kt new file mode 100644 index 0000000..6a18d70 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/DeleteEbookCommand.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.ebook.v1.dto.command + +import com.devooks.backend.wishlist.v1.error.validateEbookId +import java.util.* + +class DeleteEbookCommand( + val ebookId: UUID, + val requesterId: UUID, +) { + constructor( + ebookId: String, + requesterId: UUID, + ) : this( + ebookId = ebookId.validateEbookId(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/DeleteEbookInquiryCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/DeleteEbookInquiryCommand.kt new file mode 100644 index 0000000..f3ae696 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/DeleteEbookInquiryCommand.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.ebook.v1.dto.command + +import com.devooks.backend.ebook.v1.error.validateEbookInquiryId +import java.util.* + +class DeleteEbookInquiryCommand( + val inquiryId: UUID, + val requesterId: UUID, +) { + constructor( + inquiryId: String, + requesterId: UUID, + ) : this( + inquiryId = inquiryId.validateEbookInquiryId(), + requesterId = requesterId, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/DeleteEbookInquiryCommentCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/DeleteEbookInquiryCommentCommand.kt new file mode 100644 index 0000000..bd61e16 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/DeleteEbookInquiryCommentCommand.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.ebook.v1.dto.command + +import com.devooks.backend.ebook.v1.error.validateEbookInquiryCommentId +import java.util.* + +class DeleteEbookInquiryCommentCommand( + val commentId: UUID, + val requesterId: UUID, +) { + constructor( + commentId: String, + requesterId: UUID, + ) : this( + commentId = commentId.validateEbookInquiryCommentId(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetDetailOfEbookCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetDetailOfEbookCommand.kt new file mode 100644 index 0000000..eee0da2 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetDetailOfEbookCommand.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.ebook.v1.dto.command + +import com.devooks.backend.wishlist.v1.error.validateEbookId +import java.util.* + +class GetDetailOfEbookCommand( + val ebookId: UUID, + val requesterId: UUID?, +) { + constructor( + ebookId: String, + requesterId: UUID?, + ) : this( + ebookId = ebookId.validateEbookId(), + requesterId = requesterId + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetEbookCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetEbookCommand.kt new file mode 100644 index 0000000..48f584f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetEbookCommand.kt @@ -0,0 +1,46 @@ +package com.devooks.backend.ebook.v1.dto.command + +import com.devooks.backend.common.dto.Paging +import com.devooks.backend.ebook.v1.domain.EbookOrder +import com.devooks.backend.ebook.v1.domain.EbookOrder.Companion.toEbookOrder +import com.devooks.backend.ebook.v1.error.validateEbookIds +import com.devooks.backend.member.v1.error.validateMemberId +import com.devooks.backend.wishlist.v1.error.validateCategoryIds +import java.util.* + +class GetEbookCommand( + val title: String?, + val sellingMemberId: UUID?, + val ebookIdList: List?, + val categoryIdList: List?, + val orderBy: EbookOrder, + val requesterId: UUID?, + private val paging: Paging, +) { + + constructor( + title: String, + sellingMemberId: String, + ebookIdList: List, + categoryIdList: List, + orderBy: String, + requesterId: UUID?, + page: String, + count: String, + ) : this( + title = title.takeIf { it.isNotBlank() }?.let { "%$title%" }, + sellingMemberId = sellingMemberId.takeIf { it.isNotBlank() }?.let { it.validateMemberId() }, + ebookIdList = ebookIdList.takeIf { it.isNotEmpty() }?.validateEbookIds(), + categoryIdList = categoryIdList.takeIf { it.isNotEmpty() }?.validateCategoryIds(), + orderBy = orderBy.takeIf { it.isNotBlank() }?.toEbookOrder() ?: EbookOrder.LATEST, + requesterId = requesterId, + paging = Paging(page, count) + ) + + val offset: Int + get() = paging.offset + + val limit: Int + get() = paging.limit + +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetEbookInquireCommentsCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetEbookInquireCommentsCommand.kt new file mode 100644 index 0000000..42030dc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetEbookInquireCommentsCommand.kt @@ -0,0 +1,23 @@ +package com.devooks.backend.ebook.v1.dto.command + +import com.devooks.backend.common.dto.Paging +import com.devooks.backend.ebook.v1.error.validateEbookInquiryId +import java.util.* +import org.springframework.data.domain.Pageable + +class GetEbookInquireCommentsCommand( + val inquiryId: UUID, + private val paging: Paging, +) { + constructor( + inquiryId: String, + page: String, + count: String, + ) : this( + inquiryId = inquiryId.validateEbookInquiryId(), + paging = Paging(page, count), + ) + + val pageable: Pageable + get() = paging.value +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetEbookInquiresCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetEbookInquiresCommand.kt new file mode 100644 index 0000000..53dcd62 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/GetEbookInquiresCommand.kt @@ -0,0 +1,23 @@ +package com.devooks.backend.ebook.v1.dto.command + +import com.devooks.backend.common.dto.Paging +import com.devooks.backend.wishlist.v1.error.validateEbookId +import java.util.* +import org.springframework.data.domain.Pageable + +class GetEbookInquiresCommand( + val ebookId: UUID, + private val paging: Paging, +) { + constructor( + ebookId: String, + page: String, + count: String, + ) : this( + ebookId = ebookId.validateEbookId(), + paging = Paging(page, count) + ) + + val pageable: Pageable + get() = paging.value +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/ModifyEbookCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/ModifyEbookCommand.kt new file mode 100644 index 0000000..7c67ec4 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/ModifyEbookCommand.kt @@ -0,0 +1,20 @@ +package com.devooks.backend.ebook.v1.dto.command + +import java.util.* + +class ModifyEbookCommand( + val ebookId: UUID, + val title: String?, + val relatedCategoryNameList: List?, + val mainImageId: UUID?, + val descriptionImageIdList: List?, + val introduction: String?, + val tableOfContents: String?, + val price: Int?, + val requesterId: UUID, +) { + val isChangedEbook: Boolean = + title != null || mainImageId != null || introduction != null || tableOfContents != null || price != null + val isChangedDescriptionImageList: Boolean = descriptionImageIdList != null + val isChangedRelatedCategoryNameList: Boolean = relatedCategoryNameList != null +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/ModifyEbookInquiryCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/ModifyEbookInquiryCommand.kt new file mode 100644 index 0000000..29a036e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/ModifyEbookInquiryCommand.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.ebook.v1.dto.command + +import java.util.* + +class ModifyEbookInquiryCommand( + val content: String, + val inquiryId: UUID, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/ModifyEbookInquiryCommentCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/ModifyEbookInquiryCommentCommand.kt new file mode 100644 index 0000000..7f94e0a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/ModifyEbookInquiryCommentCommand.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.ebook.v1.dto.command + +import java.util.* + +class ModifyEbookInquiryCommentCommand( + val content: String, + val commentId: UUID, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/SaveDescriptionImagesCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/SaveDescriptionImagesCommand.kt new file mode 100644 index 0000000..c02e6fa --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/SaveDescriptionImagesCommand.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.ebook.v1.dto.command + +import com.devooks.backend.common.domain.Image +import java.util.* + +class SaveDescriptionImagesCommand( + val imageList: List, + val requesterId: UUID +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/SaveMainImageCommand.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/SaveMainImageCommand.kt new file mode 100644 index 0000000..b90048e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/command/SaveMainImageCommand.kt @@ -0,0 +1,10 @@ +package com.devooks.backend.ebook.v1.dto.command + +import com.devooks.backend.common.domain.Image +import java.util.* + +class SaveMainImageCommand( + val image: Image, + val requesterId: UUID, +) + diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/CreateEbookInquiryRequest.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/CreateEbookInquiryRequest.kt new file mode 100644 index 0000000..5714287 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/CreateEbookInquiryRequest.kt @@ -0,0 +1,18 @@ +package com.devooks.backend.ebook.v1.dto.request + +import com.devooks.backend.ebook.v1.dto.command.CreateEbookInquiryCommand +import com.devooks.backend.ebook.v1.error.validateEbookInquiryContent +import com.devooks.backend.wishlist.v1.error.validateEbookId +import java.util.* + +data class CreateEbookInquiryRequest( + val ebookId: String?, + val content: String?, +) { + fun toCommand(requesterId: UUID) = + CreateEbookInquiryCommand( + ebookId = ebookId.validateEbookId(), + content = content.validateEbookInquiryContent(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/CreateEbookRequest.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/CreateEbookRequest.kt new file mode 100644 index 0000000..4df407e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/CreateEbookRequest.kt @@ -0,0 +1,36 @@ +package com.devooks.backend.ebook.v1.dto.request + +import com.devooks.backend.ebook.v1.dto.command.CreateEbookCommand +import com.devooks.backend.ebook.v1.error.validateDescriptionImageIdList +import com.devooks.backend.ebook.v1.error.validateEbookIntroduction +import com.devooks.backend.ebook.v1.error.validateEbookPrice +import com.devooks.backend.ebook.v1.error.validateEbookTitle +import com.devooks.backend.ebook.v1.error.validateMainImageId +import com.devooks.backend.ebook.v1.error.validatePdfId +import com.devooks.backend.ebook.v1.error.validateRelatedCategoryList +import com.devooks.backend.ebook.v1.error.validateTableOfContents +import java.util.* + +data class CreateEbookRequest( + val pdfId: String?, + val title: String?, + val relatedCategoryNameList: List?, + val mainImageId: String?, + val descriptionImageIdList: List?, + val price: Int?, + val introduction: String?, + val tableOfContents: String?, +) { + fun toCommand(requesterId: UUID): CreateEbookCommand = + CreateEbookCommand( + pdfId = pdfId.validatePdfId(), + title = title.validateEbookTitle(), + relatedCategoryNameList = relatedCategoryNameList.validateRelatedCategoryList(), + mainImageId = mainImageId.validateMainImageId(), + descriptionImageIdList = descriptionImageIdList.validateDescriptionImageIdList(), + price = price.validateEbookPrice(), + introduction = introduction.validateEbookIntroduction(), + tableOfContents = tableOfContents.validateTableOfContents(), + sellingMemberId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/EbookInquiryCommentRequest.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/EbookInquiryCommentRequest.kt new file mode 100644 index 0000000..29b588b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/EbookInquiryCommentRequest.kt @@ -0,0 +1,19 @@ +package com.devooks.backend.ebook.v1.dto.request + +import com.devooks.backend.ebook.v1.dto.command.CreateEbookInquiryCommentCommand +import com.devooks.backend.ebook.v1.error.validateEbookInquiryCommentContent +import com.devooks.backend.ebook.v1.error.validateEbookInquiryId +import java.util.* + +data class CreateEbookInquiryCommentRequest( + val inquiryId: String?, + val content: String?, +) { + fun toCommand(requesterId: UUID) = + CreateEbookInquiryCommentCommand( + inquiryId = inquiryId.validateEbookInquiryId(), + content = content.validateEbookInquiryCommentContent(), + requesterId = requesterId + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/ModifyEbookInquiryCommentRequest.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/ModifyEbookInquiryCommentRequest.kt new file mode 100644 index 0000000..bbc320a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/ModifyEbookInquiryCommentRequest.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.ebook.v1.dto.request + +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookInquiryCommentCommand +import com.devooks.backend.ebook.v1.error.validateEbookInquiryCommentContent +import com.devooks.backend.ebook.v1.error.validateEbookInquiryCommentId +import java.util.* + +data class ModifyEbookInquiryCommentRequest( + val content: String?, +) { + fun toCommand(commentId: String, requesterId: UUID) = + ModifyEbookInquiryCommentCommand( + content = content.validateEbookInquiryCommentContent(), + commentId = commentId.validateEbookInquiryCommentId(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/ModifyEbookInquiryRequest.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/ModifyEbookInquiryRequest.kt new file mode 100644 index 0000000..82335d5 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/ModifyEbookInquiryRequest.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.ebook.v1.dto.request + +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookInquiryCommand +import com.devooks.backend.ebook.v1.error.validateEbookInquiryContent +import com.devooks.backend.ebook.v1.error.validateEbookInquiryId +import java.util.* + +data class ModifyEbookInquiryRequest( + val content: String?, +) { + fun toCommand(inquiryId: String, requesterId: UUID) = + ModifyEbookInquiryCommand( + content = content.validateEbookInquiryContent(), + inquiryId = inquiryId.validateEbookInquiryId(), + requesterId = requesterId, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/ModifyEbookRequest.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/ModifyEbookRequest.kt new file mode 100644 index 0000000..99b78ba --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/ModifyEbookRequest.kt @@ -0,0 +1,60 @@ +package com.devooks.backend.ebook.v1.dto.request + +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookCommand +import com.devooks.backend.ebook.v1.error.EbookError +import com.devooks.backend.ebook.v1.error.validateDescriptionImageIdList +import com.devooks.backend.ebook.v1.error.validateEbookIntroduction +import com.devooks.backend.ebook.v1.error.validateEbookPrice +import com.devooks.backend.ebook.v1.error.validateEbookTitle +import com.devooks.backend.ebook.v1.error.validateMainImageId +import com.devooks.backend.ebook.v1.error.validateRelatedCategoryList +import com.devooks.backend.ebook.v1.error.validateTableOfContents +import com.devooks.backend.wishlist.v1.error.validateEbookId +import java.util.* + +data class ModifyEbookRequest( + val ebook: Ebook?, + val isChanged: IsChanged?, +) { + data class Ebook( + val title: String? = null, + val relatedCategoryNameList: List? = null, + val mainImageId: String? = null, + val descriptionImageIdList: List? = null, + val introduction: String? = null, + val tableOfContents: String? = null, + val price: Int? = null, + ) + + data class IsChanged( + val title: Boolean? = false, + val relatedCategoryNameList: Boolean? = false, + val mainImage: Boolean? = false, + val descriptionImageList: Boolean? = false, + val introduction: Boolean? = false, + val tableOfContents: Boolean? = false, + val price: Boolean? = false, + ) + + fun toCommand(ebookId: String, requesterId: UUID): ModifyEbookCommand = + if (isChanged != null) { + if (ebook != null) { + ModifyEbookCommand( + ebookId.validateEbookId(), + if (isChanged.title == true) ebook.title.validateEbookTitle() else null, + if (isChanged.relatedCategoryNameList == true) ebook.relatedCategoryNameList.validateRelatedCategoryList() else null, + if (isChanged.mainImage == true) ebook.mainImageId.validateMainImageId() else null, + if (isChanged.descriptionImageList == true) ebook.descriptionImageIdList.validateDescriptionImageIdList() else null, + if (isChanged.introduction == true) ebook.introduction.validateEbookIntroduction() else null, + if (isChanged.tableOfContents == true) ebook.tableOfContents.validateTableOfContents() else null, + if (isChanged.price == true) ebook.price.validateEbookPrice() else null, + requesterId + ) + } else { + throw EbookError.REQUIRED_IS_CHANGED_FOR_MODIFY.exception + } + } else { + throw EbookError.REQUIRED_EBOOK_FOR_MODIFY.exception + } + +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/SaveDescriptionImagesRequest.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/SaveDescriptionImagesRequest.kt new file mode 100644 index 0000000..2a649a8 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/SaveDescriptionImagesRequest.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.ebook.v1.dto.request + +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.common.error.validateImages +import com.devooks.backend.ebook.v1.dto.command.SaveDescriptionImagesCommand +import java.util.* + +data class SaveDescriptionImagesRequest( + val imageList: List? +) { + fun toCommand(requesterId: UUID) = + SaveDescriptionImagesCommand( + imageList = imageList.validateImages(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/SaveMainImageRequest.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/SaveMainImageRequest.kt new file mode 100644 index 0000000..dda6e86 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/request/SaveMainImageRequest.kt @@ -0,0 +1,35 @@ +package com.devooks.backend.ebook.v1.dto.request + +import com.devooks.backend.common.domain.Image +import com.devooks.backend.common.domain.Image.Companion.validateByteSize +import com.devooks.backend.common.domain.ImageExtension.Companion.validateImageExtension +import com.devooks.backend.common.error.CommonError +import com.devooks.backend.common.error.validateNotBlank +import com.devooks.backend.ebook.v1.dto.command.SaveMainImageCommand +import com.devooks.backend.ebook.v1.error.EbookError +import java.util.* + +data class SaveMainImageRequest( + val image: MainImageDto?, +) { + + data class MainImageDto( + val base64Raw: String?, + val extension: String?, + val byteSize: Long?, + ) { + fun toCommand() = + Image( + base64Raw = base64Raw.validateNotBlank(CommonError.REQUIRED_BASE64RAW.exception), + extension = extension.validateImageExtension(), + byteSize = byteSize.validateByteSize(), + order = 1 + ) + } + + fun toCommand(requesterId: UUID) = + SaveMainImageCommand( + image = image?.toCommand() ?: throw EbookError.REQUIRED_MAIN_IMAGE.exception, + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/CreateEbookInquiryCommentResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/CreateEbookInquiryCommentResponse.kt new file mode 100644 index 0000000..47f9abc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/CreateEbookInquiryCommentResponse.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.domain.EbookInquiryComment +import com.devooks.backend.ebook.v1.dto.EbookInquiryCommentDto +import com.devooks.backend.ebook.v1.dto.EbookInquiryCommentDto.Companion.toDto + +data class CreateEbookInquiryCommentResponse( + val comment: EbookInquiryCommentDto, +) { + companion object { + fun EbookInquiryComment.toCreateEbookInquiryCommentResponse() = + CreateEbookInquiryCommentResponse(toDto()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/CreateEbookInquiryResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/CreateEbookInquiryResponse.kt new file mode 100644 index 0000000..9cc81ae --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/CreateEbookInquiryResponse.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.domain.EbookInquiry +import com.devooks.backend.ebook.v1.dto.EbookInquiryDto +import com.devooks.backend.ebook.v1.dto.EbookInquiryDto.Companion.toDto + +data class CreateEbookInquiryResponse( + val ebookInquiry: EbookInquiryDto, +) { + companion object { + fun EbookInquiry.toCreateEbookInquiryResponse() = + CreateEbookInquiryResponse(toDto()) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/CreateEbookResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/CreateEbookResponse.kt new file mode 100644 index 0000000..812b8a4 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/CreateEbookResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.ebook.v1.dto.response + +data class CreateEbookResponse( + val ebook: EbookResponse, +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/DeleteEbookInquiryCommentResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/DeleteEbookInquiryCommentResponse.kt new file mode 100644 index 0000000..d0092a4 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/DeleteEbookInquiryCommentResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.ebook.v1.dto.response + +data class DeleteEbookInquiryCommentResponse( + val message: String = "댓글 삭제를 완료했습니다." +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/DeleteEbookInquiryResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/DeleteEbookInquiryResponse.kt new file mode 100644 index 0000000..35933b1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/DeleteEbookInquiryResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.ebook.v1.dto.response + +data class DeleteEbookInquiryResponse( + val message: String = "문의 삭제를 완료했습니다." +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/DeleteEbookResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/DeleteEbookResponse.kt new file mode 100644 index 0000000..821aede --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/DeleteEbookResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.ebook.v1.dto.response + +data class DeleteEbookResponse( + val message: String = "전자책 삭제를 완료했습니다." +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/EbookResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/EbookResponse.kt new file mode 100644 index 0000000..e78d422 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/EbookResponse.kt @@ -0,0 +1,47 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.category.v1.domain.Category +import com.devooks.backend.category.v1.dto.CategoryDto +import com.devooks.backend.category.v1.dto.CategoryDto.Companion.toDto +import com.devooks.backend.ebook.v1.domain.Ebook +import com.devooks.backend.ebook.v1.domain.EbookImage +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto.Companion.toDto +import java.time.Instant +import java.util.* + +data class EbookResponse( + val id: UUID, + val pdfId: UUID, + val mainImageId: UUID, + val relatedCategoryNameList: List, + val title: String, + val price: Int, + val tableOfContents: String, + val introduction: String, + val createdDate: Instant, + val modifiedDate: Instant, + val descriptionImageList: List, + val sellingMemberId: UUID, + val deletedDate: Instant?, +) { + constructor( + ebook: Ebook, + descriptionImageList: List, + categoryList: List, + ) : this( + id = ebook.id, + pdfId = ebook.pdfId, + mainImageId = ebook.mainImageId, + relatedCategoryNameList = categoryList.map { it.toDto() }, + title = ebook.title, + price = ebook.price, + tableOfContents = ebook.tableOfContents, + introduction = ebook.introduction, + createdDate = ebook.createdDate, + modifiedDate = ebook.modifiedDate, + sellingMemberId = ebook.sellingMemberId, + deletedDate = ebook.deletedDate, + descriptionImageList = descriptionImageList.map { it.toDto() }.sortedBy { it.order } + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetDetailOfEbookResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetDetailOfEbookResponse.kt new file mode 100644 index 0000000..aec20aa --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetDetailOfEbookResponse.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.dto.EbookDetailView + +data class GetDetailOfEbookResponse( + val ebook: EbookDetailView +) { + companion object { + fun EbookDetailView.toGetDetailOfEbookResponse() = + GetDetailOfEbookResponse(this) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetEbookInquiriesResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetEbookInquiriesResponse.kt new file mode 100644 index 0000000..5216602 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetEbookInquiriesResponse.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.domain.EbookInquiry +import com.devooks.backend.ebook.v1.dto.EbookInquiryDto +import com.devooks.backend.ebook.v1.dto.EbookInquiryDto.Companion.toDto + +data class GetEbookInquiriesResponse( + val ebookInquiryList: List, +) { + companion object { + fun List.toGetEbookInquiriesResponse() = + GetEbookInquiriesResponse(map { it.toDto() }) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetEbookInquiryCommentsResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetEbookInquiryCommentsResponse.kt new file mode 100644 index 0000000..b38c824 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetEbookInquiryCommentsResponse.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.domain.EbookInquiryComment +import com.devooks.backend.ebook.v1.dto.EbookInquiryCommentDto +import com.devooks.backend.ebook.v1.dto.EbookInquiryCommentDto.Companion.toDto + +data class GetEbookInquiryCommentsResponse( + val comments: List, +) { + companion object { + fun List.toGetEbookInquiryCommentsResponse() = + GetEbookInquiryCommentsResponse(map { it.toDto() }) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetEbooksResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetEbooksResponse.kt new file mode 100644 index 0000000..4053963 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/GetEbooksResponse.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.dto.EbookView + +data class GetEbooksResponse( + val ebookList: List, +) { + companion object { + fun List.toGetEbooksResponse() = + GetEbooksResponse(this) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/ModifyEbookInquiryCommentResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/ModifyEbookInquiryCommentResponse.kt new file mode 100644 index 0000000..167a9f9 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/ModifyEbookInquiryCommentResponse.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.domain.EbookInquiryComment +import com.devooks.backend.ebook.v1.dto.EbookInquiryCommentDto +import com.devooks.backend.ebook.v1.dto.EbookInquiryCommentDto.Companion.toDto + +data class ModifyEbookInquiryCommentResponse( + val comment: EbookInquiryCommentDto, +) { + companion object { + fun EbookInquiryComment.toModifyEbookInquiryCommentResponse() = + ModifyEbookInquiryCommentResponse(toDto()) + } +} + + diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/ModifyEbookInquiryResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/ModifyEbookInquiryResponse.kt new file mode 100644 index 0000000..522efc9 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/ModifyEbookInquiryResponse.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.domain.EbookInquiry +import com.devooks.backend.ebook.v1.dto.EbookInquiryDto +import com.devooks.backend.ebook.v1.dto.EbookInquiryDto.Companion.toDto + +data class ModifyEbookInquiryResponse( + val ebookInquiry: EbookInquiryDto +) { + companion object { + fun EbookInquiry.toModifyEbookInquiryResponse() = + ModifyEbookInquiryResponse(toDto()) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/ModifyEbookResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/ModifyEbookResponse.kt new file mode 100644 index 0000000..3c31dde --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/ModifyEbookResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.ebook.v1.dto.response + +data class ModifyEbookResponse( + val ebook: EbookResponse, +) diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/SaveDescriptionImagesResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/SaveDescriptionImagesResponse.kt new file mode 100644 index 0000000..ea20ad0 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/SaveDescriptionImagesResponse.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.domain.EbookImage +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto.Companion.toDto + +data class SaveDescriptionImagesResponse( + val descriptionImageList: List, +) { + companion object { + fun List.toSaveDescriptionImagesResponse() = + SaveDescriptionImagesResponse(map { it.toDto() }) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/SaveMainImageResponse.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/SaveMainImageResponse.kt new file mode 100644 index 0000000..a2a7d1e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/dto/response/SaveMainImageResponse.kt @@ -0,0 +1,24 @@ +package com.devooks.backend.ebook.v1.dto.response + +import com.devooks.backend.ebook.v1.domain.EbookImage +import java.util.* +import kotlin.io.path.pathString + +data class SaveMainImageResponse( + val mainImage: MainImageDto, +) { + data class MainImageDto( + val id: UUID, + val imagePath: String, + ) + + companion object { + fun EbookImage.toSaveMainImageResponse() = + SaveMainImageResponse( + MainImageDto( + id = id, + imagePath = imagePath.pathString + ) + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookEntity.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookEntity.kt new file mode 100644 index 0000000..115dff6 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookEntity.kt @@ -0,0 +1,64 @@ +package com.devooks.backend.ebook.v1.entity + +import com.devooks.backend.ebook.v1.domain.Ebook +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "ebook") +data class EbookEntity( + @Id + @Column("ebook_id") + @get:JvmName("ebookId") + val id: UUID? = null, + val sellingMemberId: UUID, + val pdfId: UUID, + val mainImageId: UUID, + val title: String, + val price: Int, + val tableOfContents: String, + val introduction: String, + val createdDate: Instant = Instant.now(), + val modifiedDate: Instant = createdDate, + val deletedDate: Instant? = null, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + Ebook( + id = id!!, + sellingMemberId = sellingMemberId, + pdfId = pdfId, + mainImageId = mainImageId, + title = title, + price = price, + tableOfContents = tableOfContents, + introduction = introduction, + createdDate = createdDate, + modifiedDate = modifiedDate, + deletedDate = deletedDate, + ) + + companion object { + fun Ebook.toEntity() = + EbookEntity( + id = id, + sellingMemberId = sellingMemberId, + pdfId = pdfId, + mainImageId = mainImageId, + title = title, + price = price, + tableOfContents = tableOfContents, + introduction = introduction, + createdDate = createdDate, + modifiedDate = modifiedDate, + deletedDate = deletedDate, + ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookImageEntity.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookImageEntity.kt new file mode 100644 index 0000000..bf63fcb --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookImageEntity.kt @@ -0,0 +1,34 @@ +package com.devooks.backend.ebook.v1.entity + +import com.devooks.backend.ebook.v1.domain.EbookImage +import java.util.* +import kotlin.io.path.Path +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "ebook_image") +data class EbookImageEntity( + @Id + @Column("ebook_image_id") + @get:JvmName("ebookImageId") + val id: UUID? = null, + val imagePath: String, + val imageOrder: Int, + val uploadMemberId: UUID, + val ebookId: UUID? = null, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + EbookImage( + id = id!!, + imagePath = Path(imagePath), + order = imageOrder, + uploadMemberId = uploadMemberId, + ebookId = ebookId + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookInquiryCommentEntity.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookInquiryCommentEntity.kt new file mode 100644 index 0000000..46f4197 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookInquiryCommentEntity.kt @@ -0,0 +1,36 @@ +package com.devooks.backend.ebook.v1.entity + +import com.devooks.backend.ebook.v1.domain.EbookInquiryComment +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table("ebook_inquiry_comment") +data class EbookInquiryCommentEntity( + @Id + @Column(value = "ebook_inquiry_comment_id") + @get:JvmName("ebookInquiryCommentId") + val id: UUID? = null, + val inquiryId: UUID, + val content: String, + val writerMemberId: UUID, + val writtenDate: Instant = Instant.now(), + val modifiedDate: Instant = writtenDate, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + EbookInquiryComment( + id = this.id!!, + inquiryId = this.inquiryId, + content = this.content, + writerMemberId = this.writerMemberId, + writtenDate = this.writtenDate, + modifiedDate = this.modifiedDate, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookInquiryEntity.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookInquiryEntity.kt new file mode 100644 index 0000000..9978888 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/EbookInquiryEntity.kt @@ -0,0 +1,36 @@ +package com.devooks.backend.ebook.v1.entity + +import com.devooks.backend.ebook.v1.domain.EbookInquiry +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "ebook_inquiry") +data class EbookInquiryEntity( + @Id + @Column(value = "ebook_inquiry_id") + @get:JvmName("ebookInquiryId") + val id: UUID? = null, + val content: String, + val ebookId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant = Instant.now(), + val modifiedDate: Instant = writtenDate, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + EbookInquiry( + id = this.id!!, + content = this.content, + ebookId = this.ebookId, + writerMemberId = this.writerMemberId, + writtenDate = this.writtenDate, + modifiedDate = this.modifiedDate, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/entity/RelatedCategoryEntity.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/RelatedCategoryEntity.kt new file mode 100644 index 0000000..c2e596b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/entity/RelatedCategoryEntity.kt @@ -0,0 +1,21 @@ +package com.devooks.backend.ebook.v1.entity + +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "related_category") +data class RelatedCategoryEntity( + @Id + @Column("related_category_id") + @get:JvmName("relatedCategoryId") + val id: UUID? = null, + val ebookId: UUID, + val categoryId: UUID, +): Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/error/EbookError.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/error/EbookError.kt new file mode 100644 index 0000000..68084f9 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/error/EbookError.kt @@ -0,0 +1,48 @@ +package com.devooks.backend.ebook.v1.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.NOT_FOUND + +enum class EbookError(val exception: GeneralException) { + // 400 + REQUIRED_PDF_ID(GeneralException("EBOOK-400-1", BAD_REQUEST, "PDF 식별자가 반드시 필요합니다.")), + INVALID_PDF_ID(GeneralException("EBOOK-400-2", BAD_REQUEST, "잘못된 형식의 PDF 식별자 입니다.")), + REQUIRED_TITLE(GeneralException("EBOOK-400-3", BAD_REQUEST, "전자책 제목이 반드시 필요합니다.")), + REQUIRED_RELATED_CATEGORY_LIST(GeneralException("EBOOK-400-4", BAD_REQUEST, "관련 카테고리가 반드시 필요합니다.")), + INVALID_EBOOK_PRICE(GeneralException("EBOOK-400-5", BAD_REQUEST, "유효하지 않은 가격입니다.")), + REQUIRED_EBOOK_INTRODUCTION(GeneralException("EBOOK-400-6", BAD_REQUEST, "전자책 소개가 반드시 필요합니다.")), + REQUIRED_TABLE_OF_CONTENTS(GeneralException("EBOOK-400-7", BAD_REQUEST, "목차가 반드시 필요합니다.")), + INVALID_TOP_100(GeneralException("EBOOK-400-8", BAD_REQUEST, "잘못된 형식의 TOP100(ex. DAILY, WEEKLY, MONTHLY) 입니다.")), + INVALID_EBOOK_ORDER(GeneralException("EBOOK-400-9", BAD_REQUEST, "잘못된 형식의 EbookOrder(ex. LATEST, REVIEW) 입니다.")), + REQUIRED_EBOOK_INQUIRY_CONTENT(GeneralException("EBOOK-400-10", BAD_REQUEST, "문의 내용이 반드시 필요합니다.")), + REQUIRED_EBOOK_INQUIRY_ID(GeneralException("EBOOK-400-11", BAD_REQUEST, "문의 식별자가 반드시 필요합니다.")), + INVALID_EBOOK_INQUIRY_ID(GeneralException("EBOOK-400-12", BAD_REQUEST, "잘못된 형식의 문의 식별자입니다.")), + REQUIRED_EBOOK_INQUIRY_COMMENT_CONTENT(GeneralException("EBOOK-400-13", BAD_REQUEST, "댓글 내용이 반드시 필요합니다.")), + REQUIRED_EBOOK_INQUIRY_COMMENT_ID(GeneralException("EBOOK-400-14", BAD_REQUEST, "댓글 식별자가 반드시 필요합니다.")), + INVALID_EBOOK_INQUIRY_COMMENT_ID(GeneralException("EBOOK-400-15", BAD_REQUEST, "잘못된 형식의 댓글 식별자입니다.")), + INVALID_EBOOK_ID(GeneralException("EBOOK-400-16", BAD_REQUEST, "잘못된 형식의 전자책 식별자입니다.")), + REQUIRED_EBOOK_FOR_MODIFY(GeneralException("EBOOK-400-17", BAD_REQUEST, "전자책이 반드시 필요합니다.")), + REQUIRED_IS_CHANGED_FOR_MODIFY(GeneralException("EBOOK-400-18", BAD_REQUEST, "수졍 여부가 반드시 필요합니다.")), + REQUIRED_MAIN_IMAGE(GeneralException("EBOOK-400-19", BAD_REQUEST, "메인 사진이 반드시 필요합니다.")), + REQUIRED_MAIN_IMAGE_ID(GeneralException("EBOOK-400-20", BAD_REQUEST, "메인 사진 식별자가 반드시 필요합니다.")), + INVALID_MAIN_IMAGE_ID(GeneralException("EBOOK-400-21", BAD_REQUEST, "잘못된 형식의 메인 사진 식별자입니다.")), + REQUIRED_DESCRIPTION_IMAGE_ID(GeneralException("EBOOK-400-22", BAD_REQUEST, "설명 사진 식별자가 반드시 필요합니다.")), + INVALID_DESCRIPTION_IMAGE_ID(GeneralException("EBOOK-400-23", BAD_REQUEST, "잘못된 형식의 설명 사진 식별자입니다.")), + + // 403 + FORBIDDEN_BUYER_MEMBER_ID(GeneralException("EBOOK-403-1", FORBIDDEN, "자신의 책을 구매하는 것은 불가능합니다.")), + FORBIDDEN_MODIFY_EBOOK_INQUIRY(GeneralException("EBOOK-403-2", FORBIDDEN, "자신이 작성한 문의만 수정할 수 있습니다.")), + FORBIDDEN_MODIFY_EBOOK_INQUIRY_COMMENT(GeneralException("EBOOK-403-3", FORBIDDEN, "자신이 작성한 댓글만 수정할 수 있습니다.")), + FORBIDDEN_MODIFY_EBOOK(GeneralException("EBOOK-403-4", FORBIDDEN, "자신이 등록한 전자책만 수정할 수 있습니다.")), + FORBIDDEN_REGISTER_EBOOK_TO_IMAGE(GeneralException("EBOOK-403-5", FORBIDDEN, "자신이 등록한 사진만 전자책에 등록할 수 있습니다.")), + FORBIDDEN_DELETE_EBOOK(GeneralException("EBOOK-403-6", FORBIDDEN, "자신이 등록한 전자책만 삭제할 수 있습니다.")), + + // 404 + NOT_FOUND_EBOOK(GeneralException("EBOOK-404-1", NOT_FOUND, "전자책을 찾을 수 없습니다")), + NOT_FOUND_EBOOK_INQUIRY(GeneralException("EBOOK-404-2", NOT_FOUND, "문의을 찾을 수 없습니다")), + NOT_FOUND_EBOOK_INQUIRY_COMMENT(GeneralException("EBOOK-404-3", NOT_FOUND, "댓글을 찾을 수 없습니다")), + NOT_FOUND_MAIN_IMAGE(GeneralException("EBOOK-404-4", NOT_FOUND, "메인 사진을 찾을 수 없습니다")), + NOT_FOUND_DESCRIPTION_IMAGE(GeneralException("EBOOK-404-5", NOT_FOUND, "설명 사진을 찾을 수 없습니다")), +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/error/EbookValidation.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/error/EbookValidation.kt new file mode 100644 index 0000000..14ee0c0 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/error/EbookValidation.kt @@ -0,0 +1,52 @@ +package com.devooks.backend.ebook.v1.error + +import com.devooks.backend.common.error.validateNotBlank +import com.devooks.backend.common.error.validateNotEmpty +import com.devooks.backend.common.error.validateNotNull +import com.devooks.backend.common.error.validateUUID +import java.util.* + +fun String?.validatePdfId(): UUID = + validateNotNull(EbookError.REQUIRED_PDF_ID.exception) + .validateUUID(EbookError.INVALID_PDF_ID.exception) + +fun String?.validateEbookTitle(): String = + validateNotBlank(EbookError.REQUIRED_TITLE.exception) + +fun List?.validateRelatedCategoryList(): List = + validateNotEmpty(EbookError.REQUIRED_RELATED_CATEGORY_LIST.exception) + +fun Int?.validateEbookPrice(): Int = + takeIf { it != null && it in 0..9_999_999 } + ?: throw EbookError.INVALID_EBOOK_PRICE.exception + +fun String?.validateEbookIntroduction(): String = + validateNotBlank(EbookError.REQUIRED_EBOOK_INTRODUCTION.exception) + +fun String?.validateTableOfContents(): String = + validateNotBlank(EbookError.REQUIRED_TABLE_OF_CONTENTS.exception) + +fun String?.validateEbookInquiryContent(): String = + validateNotBlank(EbookError.REQUIRED_EBOOK_INQUIRY_CONTENT.exception) + +fun String?.validateEbookInquiryId(): UUID = + validateNotBlank(EbookError.REQUIRED_EBOOK_INQUIRY_ID.exception) + .validateUUID(EbookError.INVALID_EBOOK_INQUIRY_ID.exception) + +fun String?.validateEbookInquiryCommentContent(): String = + validateNotBlank(EbookError.REQUIRED_EBOOK_INQUIRY_COMMENT_CONTENT.exception) + +fun String?.validateEbookInquiryCommentId(): UUID = + validateNotBlank(EbookError.REQUIRED_EBOOK_INQUIRY_COMMENT_ID.exception) + .validateUUID(EbookError.INVALID_EBOOK_INQUIRY_COMMENT_ID.exception) + +fun List.validateEbookIds(): List = + map { it.validateUUID(EbookError.INVALID_EBOOK_ID.exception) } + +fun String?.validateMainImageId(): UUID = + validateNotBlank(EbookError.REQUIRED_MAIN_IMAGE_ID.exception) + .validateUUID(EbookError.INVALID_MAIN_IMAGE_ID.exception) + +fun List?.validateDescriptionImageIdList(): List = + validateNotNull(EbookError.REQUIRED_DESCRIPTION_IMAGE_ID.exception) + .map { it.validateUUID(EbookError.INVALID_DESCRIPTION_IMAGE_ID.exception) } \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookImageRepository.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookImageRepository.kt new file mode 100644 index 0000000..86bbab4 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookImageRepository.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.ebook.v1.repository + +import com.devooks.backend.ebook.v1.entity.EbookImageEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface EbookImageRepository : CoroutineCrudRepository { + suspend fun deleteAllByEbookId(ebookId: UUID) + suspend fun findAllByEbookId(ebookId: UUID): List +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookInquiryCommentRepository.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookInquiryCommentRepository.kt new file mode 100644 index 0000000..b77a7a3 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookInquiryCommentRepository.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.ebook.v1.repository + +import com.devooks.backend.ebook.v1.entity.EbookInquiryCommentEntity +import java.util.* +import kotlinx.coroutines.flow.Flow +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface EbookInquiryCommentRepository : CoroutineCrudRepository { + suspend fun findAllByInquiryId(inquiryId: UUID, pageable: Pageable): Flow +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookInquiryRepository.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookInquiryRepository.kt new file mode 100644 index 0000000..d5ce885 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookInquiryRepository.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.ebook.v1.repository + +import com.devooks.backend.ebook.v1.entity.EbookInquiryEntity +import java.util.* +import kotlinx.coroutines.flow.Flow +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.kotlin.CoroutineCrudRepository + +interface EbookInquiryRepository : CoroutineCrudRepository { + suspend fun findAllByEbookId(ebookId: UUID, pageable: Pageable): Flow +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookQueryRepository.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookQueryRepository.kt new file mode 100644 index 0000000..4b33a1c --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookQueryRepository.kt @@ -0,0 +1,235 @@ +package com.devooks.backend.ebook.v1.repository + +import com.devooks.backend.ebook.v1.domain.EbookOrder +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.EbookDetailView +import com.devooks.backend.ebook.v1.dto.EbookView +import com.devooks.backend.ebook.v1.dto.ReviewView +import com.devooks.backend.ebook.v1.dto.command.GetDetailOfEbookCommand +import com.devooks.backend.ebook.v1.dto.command.GetEbookCommand +import com.devooks.backend.ebook.v1.error.EbookError +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.r2dbc.spi.Readable +import java.math.BigDecimal +import java.math.BigInteger +import java.time.Instant +import java.util.* +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.asFlow +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.stereotype.Repository + +@Repository +class EbookQueryRepository( + private val databaseClient: DatabaseClient, +) { + private val objectMapper: ObjectMapper = ObjectMapper() + + suspend fun findBy(command: GetEbookCommand): List { + val binding = mutableMapOf() + binding["offset"] = command.offset + binding["limit"] = command.limit + val query = """ + WITH ebook_with_review AS + (SELECT e.ebook_id, + ei.image_path as main_image_path, + e.title, + e.selling_member_id, + e.created_date, + ${getWishlistId(command.requesterId, binding)} AS wishlist_id, + COALESCE(AVG(r.rating), 0) AS rating, + COUNT(r.review_id) AS count + FROM ebook e + LEFT JOIN review r ON e.ebook_id = r.ebook_id + LEFT JOIN ebook_image ei ON e.ebook_id = ei.ebook_id + WHERE e.deleted_date IS NULL + GROUP BY e.ebook_id, main_image_path, e.title, e.created_date + ), + related_category_with_name AS (SELECT r.ebook_id + , ARRAY_AGG(c.category_id) AS category_id_list + , ARRAY_AGG(c.name) AS categroy_name_list + FROM related_category r + , category c + WHERE c.category_id = r.category_id + GROUP BY r.ebook_id) + SELECT e.ebook_id, + e.main_image_path, + e.wishlist_id, + e.title, + e.rating, + e.count, + r.categroy_name_list AS related_category_name_list + FROM ebook_with_review e, + related_category_with_name r + WHERE e.ebook_id = r.ebook_id + ${ + command.title?.let { + binding["title"] = it + "AND e.title ILIKE :title" + } ?: "" + } + ${ + command.sellingMemberId?.let { + binding["sellingMemberId"] = it + "AND e.selling_member_id = :sellingMemberId" + } ?: "" + } + ${ + command.ebookIdList?.let { + binding["ebookIdList"] = it + "AND e.ebook_id in (:ebookIdList)" + } ?: "" + } + ${ + command.categoryIdList?.let { + binding["categoryIdList"] = it + "AND r.category_id_list @> ARRAY [:categoryIdList]::uuid[]" + } ?: "" + } + ${ + command.orderBy.let { + when (it) { + EbookOrder.LATEST -> { + "ORDER BY e.created_date DESC" + } + + EbookOrder.REVIEW -> { + "ORDER BY e.rating DESC" + } + } + } + } + OFFSET :offset LIMIT :limit; + """.trimIndent() + + return databaseClient + .sql(query) + .bindValues(binding) + .map { row -> mapToEbookView(row) } + .all() + .asFlow() + .toList() + } + + suspend fun findBy(command: GetDetailOfEbookCommand): EbookDetailView { + val binding = mutableMapOf() + binding["ebookId"] = command.ebookId + val query = """ + WITH related_category_name AS (SELECT r.ebook_id, ARRAY_AGG(c.name) AS category_name_list + FROM related_category r, + category c + WHERE r.category_id = c.category_id + GROUP BY r.ebook_id), + ebook_with_review AS (SELECT e.ebook_id, + e.title, + ei.image_path, + e.selling_member_id, + e.created_date, + e.modified_date, + e.price, + e.introduction, + e.table_of_contents, + e.pdf_id, + e.main_image_id, + COALESCE(AVG(r.rating), 0) AS rating, + COUNT(r.review_id) AS count + FROM ebook e + LEFT JOIN review r ON e.ebook_id = r.ebook_id + LEFT JOIN ebook_image ei ON e.main_image_id = ei.ebook_image_id + GROUP BY e.ebook_id, ei.ebook_image_id) + SELECT e.ebook_id, + e.title, + e.image_path as main_image_path, + e.selling_member_id, + e.created_date, + e.modified_date, + e.price, + e.introduction, + e.table_of_contents, + e.rating, + e.count, + r.category_name_list, + (SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(ei.*))) + FROM ebook_image ei + WHERE e.ebook_id = :ebookId and e.main_image_id != ei.ebook_image_id) + AS description_image_path_list, + ${getWishlistId(command.requesterId, binding)} AS wishlist_id, + p.pdf_id, + p.page_count + FROM ebook_with_review e, + related_category_name r, + pdf p + WHERE e.ebook_id = r.ebook_id + AND e.pdf_id = p.pdf_id + AND e.ebook_id = :ebookId; + """.trimIndent() + + return databaseClient + .sql(query) + .bindValues(binding) + .map { row -> mapToEbookDetailView(row) } + .all() + .asFlow() + .firstOrNull() + ?: throw EbookError.NOT_FOUND_EBOOK.exception + } + + private fun getWishlistId( + requesterId: UUID?, + binding: MutableMap, + ) = requesterId?.let { + binding["requesterId"] = it + """ + (SELECT wishlist_id + FROM wishlist + WHERE wishlist.ebook_id = e.ebook_id + AND wishlist.member_id = :requesterId) + """.trimIndent() + } ?: "null" + + private fun mapToEbookDetailView(row: Readable) = + EbookDetailView( + id = row.get("ebook_id", UUID::class.java)!!, + mainImagePath = row.get("main_image_path", String::class.java)!!, + title = row.get("title", String::class.java)!!, + sellingMemberId = row.get("selling_member_id", UUID::class.java)!!, + createdDate = row.get("created_date", Instant::class.java)!!, + modifiedDate = row.get("modified_date", Instant::class.java)!!, + price = row.get("price", BigInteger::class.java)!!.toInt(), + pdfId = row.get("pdf_id", UUID::class.java)!!, + introduction = row.get("introduction", String::class.java)!!, + tableOfContents = row.get("table_of_contents", String::class.java)!!, + relatedCategoryNameList = row.get("category_name_list", Array::class.java)!!.toList(), + descriptionImagePathList = row.get("description_image_path_list", String::class.java)?.let { + val imagePathList = objectMapper.readValue>>(it) + imagePathList.map { imagePath -> + DescriptionImageDto( + id = UUID.fromString(imagePath["ebook_image_id"]!!), + imagePath = imagePath["image_path"]!!, + order = imagePath["image_order"]!!.toInt(), + ) + } + }, + pageCount = row.get("page_count", BigInteger::class.java)!!.toInt(), + review = ReviewView( + rating = row.get("rating", BigDecimal::class.java)!!.toDouble(), + count = row.get("count", BigInteger::class.java)!!.toInt(), + ), + wishlistId = row.get("wishlist_id", UUID::class.java) + ) + + private fun mapToEbookView(row: Readable) = + EbookView( + id = row.get("ebook_id", UUID::class.java)!!, + mainImagePath = row.get("main_image_path", String::class.java)!!, + wishlistId = row.get("wishlist_id", UUID::class.java), + title = row.get("title", String::class.java)!!, + review = ReviewView( + rating = row.get("rating", BigDecimal::class.java)!!.toDouble(), + count = row.get("count", BigInteger::class.java)!!.toInt(), + ), + relatedCategoryNameList = row.get("related_category_name_list", Array::class.java)!!.toList(), + ) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookRepository.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookRepository.kt new file mode 100644 index 0000000..0159c8b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/EbookRepository.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.ebook.v1.repository + +import com.devooks.backend.ebook.v1.entity.EbookEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface EbookRepository : CoroutineCrudRepository diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/repository/RelatedCategoryRepository.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/RelatedCategoryRepository.kt new file mode 100644 index 0000000..612d369 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/repository/RelatedCategoryRepository.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.ebook.v1.repository + +import com.devooks.backend.ebook.v1.entity.RelatedCategoryEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface RelatedCategoryRepository : CoroutineCrudRepository { + suspend fun findAllByEbookId(ebookId: UUID): List + suspend fun deleteAllByEbookId(ebookId: UUID) +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookImageService.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookImageService.kt new file mode 100644 index 0000000..d3d9300 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookImageService.kt @@ -0,0 +1,106 @@ +package com.devooks.backend.ebook.v1.service + +import com.devooks.backend.BackendApplication.Companion.DESCRIPTION_IMAGE_ROOT_PATH +import com.devooks.backend.common.domain.Image +import com.devooks.backend.common.utils.saveImage +import com.devooks.backend.ebook.v1.domain.Ebook +import com.devooks.backend.ebook.v1.domain.EbookImage +import com.devooks.backend.ebook.v1.dto.command.CreateEbookCommand +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookCommand +import com.devooks.backend.ebook.v1.entity.EbookImageEntity +import com.devooks.backend.ebook.v1.error.EbookError +import com.devooks.backend.ebook.v1.repository.EbookImageRepository +import java.util.* +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.count +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +@Service +class EbookImageService( + private val ebookImageRepository: EbookImageRepository, +) { + + suspend fun save(imageList: List, requesterId: UUID): List = + imageList + .asFlow() + .map { + EbookImageEntity( + imagePath = saveImage(it, DESCRIPTION_IMAGE_ROOT_PATH), + imageOrder = it.order, + uploadMemberId = requesterId, + ) + } + .let { ebookImageRepository.saveAll(it) } + .map { it.toDomain() } + .toList() + + suspend fun save(descriptionImageIdList: List, ebook: Ebook): List = + ebookImageRepository + .findAllById(descriptionImageIdList) + .takeIf { ebookImageList -> + ebookImageList.filter { ebookImage -> + ebookImage.uploadMemberId != ebook.sellingMemberId + }.count() == 0 + } + ?.map { it.copy(ebookId = ebook.id) } + ?.let { ebookImageRepository.saveAll(it) } + ?.map { it.toDomain() } + ?.toList() + ?: throw EbookError.FORBIDDEN_REGISTER_EBOOK_TO_IMAGE.exception + + suspend fun modifyMainImage(command: ModifyEbookCommand) { + val mainImageId = command.mainImageId + if (mainImageId != null) { + val mainImage = findById(mainImageId) + ebookImageRepository.deleteById(mainImageId) + ebookImageRepository.save(mainImage.copy(id = null, ebookId = command.ebookId)) + } + } + + suspend fun modifyDescriptionImageList(command: ModifyEbookCommand, ebook: Ebook): List { + val descriptionImageList = ebookImageRepository + .findAllByEbookId(command.ebookId) + .filter { image -> image.id!! != ebook.mainImageId } + + val ebookImageList = + if (command.isChangedDescriptionImageList) { + val changeDescriptionImageIdList = command.descriptionImageIdList!! + + val (deletedImages, existImages) = + descriptionImageList + .partition { image -> + changeDescriptionImageIdList.all { descriptionImageId -> + image.id != descriptionImageId + } + } + + val newImageList = + changeDescriptionImageIdList.filter { change -> existImages.none { it.id == change } } + val newEbookImageList = save(newImageList, ebook) + + ebookImageRepository.deleteAll(deletedImages) + + newEbookImageList.plus(existImages.map { it.toDomain() }) + } else { + descriptionImageList.map { it.toDomain() } + } + return ebookImageList.sortedBy { it.order } + } + + suspend fun validate(command: CreateEbookCommand) { + findById(command.mainImageId) + + ebookImageRepository + .findAllById(command.descriptionImageIdList) + .takeIf { it.toList().size == command.descriptionImageIdList.size } + ?: throw EbookError.NOT_FOUND_DESCRIPTION_IMAGE.exception + } + + private suspend fun findById(imageId: UUID): EbookImageEntity = + ebookImageRepository + .findById(imageId) + ?: throw EbookError.NOT_FOUND_MAIN_IMAGE.exception +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryCommentEventService.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryCommentEventService.kt new file mode 100644 index 0000000..61101f5 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryCommentEventService.kt @@ -0,0 +1,31 @@ +package com.devooks.backend.ebook.v1.service + +import com.devooks.backend.ebook.v1.domain.EbookInquiryComment +import com.devooks.backend.member.v1.service.MemberService +import com.devooks.backend.notification.v1.domain.event.CreateEbookInquiryCommentEvent +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class EbookInquiryCommentEventService( + private val memberService: MemberService, + private val ebookInquiryService: EbookInquiryService, + private val ebookService: EbookService, + private val publisher: ApplicationEventPublisher, +) { + + suspend fun publish(ebookInquiryComment: EbookInquiryComment) { + val member = memberService.findById(ebookInquiryComment.writerMemberId) + val ebookInquiry = ebookInquiryService.findById(ebookInquiryComment.inquiryId) + val ebook = ebookService.findById(ebookInquiry.ebookId) + val createEbookInquiryCommentEvent = CreateEbookInquiryCommentEvent( + ebookInquiryCommentId = ebookInquiryComment.id, + ebookInquiryId = ebookInquiry.id!!, + commenterName = member.nickname, + ebookId = ebook.id, + writtenDate = ebookInquiryComment.writtenDate, + receiverId = ebookInquiry.writerMemberId + ) + publisher.publishEvent(createEbookInquiryCommentEvent) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryCommentService.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryCommentService.kt new file mode 100644 index 0000000..5fce041 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryCommentService.kt @@ -0,0 +1,59 @@ +package com.devooks.backend.ebook.v1.service + +import com.devooks.backend.ebook.v1.domain.EbookInquiryComment +import com.devooks.backend.ebook.v1.dto.command.CreateEbookInquiryCommentCommand +import com.devooks.backend.ebook.v1.dto.command.DeleteEbookInquiryCommentCommand +import com.devooks.backend.ebook.v1.dto.command.GetEbookInquireCommentsCommand +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookInquiryCommentCommand +import com.devooks.backend.ebook.v1.entity.EbookInquiryCommentEntity +import com.devooks.backend.ebook.v1.error.EbookError +import com.devooks.backend.ebook.v1.repository.EbookInquiryCommentRepository +import java.time.Instant +import java.util.* +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +@Service +class EbookInquiryCommentService( + private val ebookInquiryCommentRepository: EbookInquiryCommentRepository, +) { + suspend fun create(command: CreateEbookInquiryCommentCommand): EbookInquiryComment { + val entity = EbookInquiryCommentEntity( + inquiryId = command.inquiryId, + content = command.content, + writerMemberId = command.requesterId, + ) + return ebookInquiryCommentRepository.save(entity).toDomain() + } + + suspend fun get(command: GetEbookInquireCommentsCommand): List = + ebookInquiryCommentRepository + .findAllByInquiryId(command.inquiryId, command.pageable) + .map { it.toDomain() } + .toList() + + suspend fun modify(command: ModifyEbookInquiryCommentCommand): EbookInquiryComment = + findBy(command.commentId) + .also { comment -> validateRequesterId(comment, command.requesterId) } + .copy(content = command.content, modifiedDate = Instant.now()) + .let { ebookInquiryCommentRepository.save(it) } + .toDomain() + + suspend fun delete(command: DeleteEbookInquiryCommentCommand) { + findBy(command.commentId) + .also { comment -> validateRequesterId(comment, command.requesterId) } + .also { comment -> ebookInquiryCommentRepository.delete(comment) } + } + + private fun validateRequesterId(comment: EbookInquiryCommentEntity, requesterId: UUID) { + comment + .takeIf { it.writerMemberId == requesterId } + ?: throw EbookError.FORBIDDEN_MODIFY_EBOOK_INQUIRY_COMMENT.exception + } + + private suspend fun findBy(commentId: UUID): EbookInquiryCommentEntity = + ebookInquiryCommentRepository + .findById(commentId) + ?: throw EbookError.NOT_FOUND_EBOOK_INQUIRY_COMMENT.exception +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryEventService.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryEventService.kt new file mode 100644 index 0000000..14c4528 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryEventService.kt @@ -0,0 +1,28 @@ +package com.devooks.backend.ebook.v1.service + +import com.devooks.backend.ebook.v1.domain.EbookInquiry +import com.devooks.backend.member.v1.service.MemberService +import com.devooks.backend.notification.v1.domain.event.CreateEbookInquiryEvent +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class EbookInquiryEventService( + private val memberService: MemberService, + private val ebookService: EbookService, + private val publisher: ApplicationEventPublisher, +) { + suspend fun publish(ebookInquiry: EbookInquiry) { + val member = memberService.findById(ebookInquiry.writerMemberId) + val ebook = ebookService.findById(ebookInquiry.ebookId) + val createEbookInquiryEvent = CreateEbookInquiryEvent( + ebookInquiryId = ebookInquiry.id, + inquirerName = member.nickname, + ebookId = ebook.id, + ebookTitle = ebook.title, + writtenDate = ebookInquiry.writtenDate, + receiverId = ebook.sellingMemberId + ) + publisher.publishEvent(createEbookInquiryEvent) + } +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryService.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryService.kt new file mode 100644 index 0000000..55c8897 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookInquiryService.kt @@ -0,0 +1,64 @@ +package com.devooks.backend.ebook.v1.service + +import com.devooks.backend.ebook.v1.domain.EbookInquiry +import com.devooks.backend.ebook.v1.dto.command.CreateEbookInquiryCommand +import com.devooks.backend.ebook.v1.dto.command.CreateEbookInquiryCommentCommand +import com.devooks.backend.ebook.v1.dto.command.DeleteEbookInquiryCommand +import com.devooks.backend.ebook.v1.dto.command.GetEbookInquiresCommand +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookInquiryCommand +import com.devooks.backend.ebook.v1.entity.EbookInquiryEntity +import com.devooks.backend.ebook.v1.error.EbookError +import com.devooks.backend.ebook.v1.repository.EbookInquiryRepository +import java.time.Instant +import java.util.* +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +@Service +class EbookInquiryService( + private val ebookInquiryRepository: EbookInquiryRepository, +) { + suspend fun create(command: CreateEbookInquiryCommand): EbookInquiry { + val entity = EbookInquiryEntity( + ebookId = command.ebookId, + content = command.content, + writerMemberId = command.requesterId + ) + return ebookInquiryRepository.save(entity).toDomain() + } + + suspend fun get(command: GetEbookInquiresCommand): List = + ebookInquiryRepository + .findAllByEbookId(command.ebookId, command.pageable) + .map { it.toDomain() } + .toList() + + suspend fun modify(command: ModifyEbookInquiryCommand): EbookInquiry = + findById(command.inquiryId) + .also { ebookInquiry -> validateRequesterId(ebookInquiry, command.requesterId) } + .copy(content = command.content, modifiedDate = Instant.now()) + .let { ebookInquiryRepository.save(it) } + .toDomain() + + suspend fun delete(command: DeleteEbookInquiryCommand) { + findById(command.inquiryId) + .also { inquiry -> validateRequesterId(inquiry, command.requesterId) } + .also { inquiry -> ebookInquiryRepository.delete(inquiry) } + } + + suspend fun validate(command: CreateEbookInquiryCommentCommand) { + findById(command.inquiryId) + } + + private fun validateRequesterId(ebookInquiryEntity: EbookInquiryEntity, requesterId: UUID) { + ebookInquiryEntity + .takeIf { it.writerMemberId == requesterId } + ?: throw EbookError.FORBIDDEN_MODIFY_EBOOK_INQUIRY.exception + } + + suspend fun findById(ebookInquiryId: UUID): EbookInquiryEntity = + ebookInquiryRepository + .findById(ebookInquiryId) + ?: throw EbookError.NOT_FOUND_EBOOK_INQUIRY.exception +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookService.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookService.kt new file mode 100644 index 0000000..8974a0c --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/service/EbookService.kt @@ -0,0 +1,101 @@ +package com.devooks.backend.ebook.v1.service + +import com.devooks.backend.ebook.v1.domain.Ebook +import com.devooks.backend.ebook.v1.dto.EbookDetailView +import com.devooks.backend.ebook.v1.dto.EbookView +import com.devooks.backend.ebook.v1.dto.command.CreateEbookCommand +import com.devooks.backend.ebook.v1.dto.command.CreateEbookInquiryCommand +import com.devooks.backend.ebook.v1.dto.command.DeleteEbookCommand +import com.devooks.backend.ebook.v1.dto.command.GetDetailOfEbookCommand +import com.devooks.backend.ebook.v1.dto.command.GetEbookCommand +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookCommand +import com.devooks.backend.ebook.v1.entity.EbookEntity +import com.devooks.backend.ebook.v1.entity.EbookEntity.Companion.toEntity +import com.devooks.backend.ebook.v1.error.EbookError +import com.devooks.backend.ebook.v1.repository.EbookQueryRepository +import com.devooks.backend.ebook.v1.repository.EbookRepository +import com.devooks.backend.review.v1.dto.CreateReviewCommand +import com.devooks.backend.transaciton.v1.dto.CreateTransactionCommand +import java.time.Instant.now +import java.util.* +import org.springframework.stereotype.Service + +@Service +class EbookService( + private val ebookRepository: EbookRepository, + private val ebookQueryRepository: EbookQueryRepository, +) { + + suspend fun create(command: CreateEbookCommand): Ebook { + val ebookEntity = ebookRepository.save( + EbookEntity( + sellingMemberId = command.sellingMemberId, + pdfId = command.pdfId, + mainImageId = command.mainImageId, + title = command.title, + price = command.price, + tableOfContents = command.tableOfContents, + introduction = command.introduction + ) + ) + return ebookEntity.toDomain() + } + + suspend fun findById(ebookId: UUID): Ebook = + ebookRepository + .findById(ebookId) + ?.also { if (it.deletedDate != null) { throw EbookError.NOT_FOUND_EBOOK.exception } } + ?.toDomain() + ?: throw EbookError.NOT_FOUND_EBOOK.exception + + suspend fun validate(command: CreateTransactionCommand) { + findById(command.ebookId) + .also { + if (it.price != command.price) { + throw EbookError.INVALID_EBOOK_PRICE.exception + } + } + .also { if (it.sellingMemberId == command.requesterId) throw EbookError.FORBIDDEN_BUYER_MEMBER_ID.exception } + } + + suspend fun validate(command: CreateReviewCommand) { + findById(command.ebookId) + } + + suspend fun validate(command: CreateEbookInquiryCommand) { + findById(command.ebookId) + } + + suspend fun get(command: GetEbookCommand): List = + ebookQueryRepository.findBy(command) + + suspend fun get(command: GetDetailOfEbookCommand): EbookDetailView { + findById(command.ebookId) + return ebookQueryRepository.findBy(command) + } + + suspend fun modify(command: ModifyEbookCommand): Ebook { + val ebook = findById(command.ebookId) + return ebook + .takeIf { command.isChangedEbook } + ?.validateToModify(command) + ?.modify(command) + ?.let { ebookRepository.save(it.toEntity()).toDomain() } + ?: ebook + } + + suspend fun delete(command: DeleteEbookCommand) { + findById(command.ebookId) + .validateToDelete(command) + .also { ebookRepository.save(it.copy(deletedDate = now()).toEntity()) } + } + + private suspend fun Ebook.validateToModify(command: ModifyEbookCommand): Ebook = + takeIf { it.sellingMemberId == command.requesterId } + ?: throw EbookError.FORBIDDEN_MODIFY_EBOOK.exception + + private suspend fun Ebook.validateToDelete(command: DeleteEbookCommand): Ebook = + takeIf { it.sellingMemberId == command.requesterId } + ?: throw EbookError.FORBIDDEN_DELETE_EBOOK.exception + +} diff --git a/src/main/kotlin/com/devooks/backend/ebook/v1/service/RelatedCategoryService.kt b/src/main/kotlin/com/devooks/backend/ebook/v1/service/RelatedCategoryService.kt new file mode 100644 index 0000000..c4eaeb0 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/ebook/v1/service/RelatedCategoryService.kt @@ -0,0 +1,46 @@ +package com.devooks.backend.ebook.v1.service + +import com.devooks.backend.category.v1.domain.Category +import com.devooks.backend.category.v1.domain.Category.Companion.toDomain +import com.devooks.backend.category.v1.entity.CategoryEntity +import com.devooks.backend.category.v1.repository.CategoryRepository +import com.devooks.backend.ebook.v1.domain.Ebook +import com.devooks.backend.ebook.v1.dto.command.ModifyEbookCommand +import com.devooks.backend.ebook.v1.entity.RelatedCategoryEntity +import com.devooks.backend.ebook.v1.repository.RelatedCategoryRepository +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +@Service +class RelatedCategoryService( + private val relatedCategoryRepository: RelatedCategoryRepository, + private val categoryRepository: CategoryRepository, +) { + suspend fun save(categoryList: List, ebook: Ebook) = + categoryList + .map { category -> RelatedCategoryEntity(ebookId = ebook.id, categoryId = category.id) } + .let { relatedCategoryRepository.saveAll(it) } + .toList() + + suspend fun modify(command: ModifyEbookCommand, ebook: Ebook): List { + return if (command.isChangedRelatedCategoryNameList) { + val relatedCategoryNameList = command.relatedCategoryNameList!! + val categoryList = relatedCategoryNameList + .map { name -> name to categoryRepository.findByNameIsIgnoreCase(name) } + .map { (name, entity) -> entity ?: categoryRepository.save(CategoryEntity(name = name)) } + .map { it.toDomain() } + .toList() + relatedCategoryRepository.deleteAllByEbookId(ebook.id) + save(categoryList, ebook) + categoryList + } else { + relatedCategoryRepository + .findAllByEbookId(command.ebookId) + .map { it.categoryId } + .let { categoryRepository.findAllById(it) } + .map { it.toDomain() } + .toList() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/controller/MemberController.kt b/src/main/kotlin/com/devooks/backend/member/v1/controller/MemberController.kt new file mode 100644 index 0000000..fb82acc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/controller/MemberController.kt @@ -0,0 +1,187 @@ +package com.devooks.backend.member.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.category.v1.domain.Category +import com.devooks.backend.category.v1.service.CategoryService +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.MemberInfo +import com.devooks.backend.member.v1.dto.GetProfileResponse +import com.devooks.backend.member.v1.dto.ModifyAccountInfoCommand +import com.devooks.backend.member.v1.dto.ModifyAccountInfoRequest +import com.devooks.backend.member.v1.dto.ModifyAccountInfoResponse +import com.devooks.backend.member.v1.dto.ModifyNicknameCommand +import com.devooks.backend.member.v1.dto.ModifyNicknameRequest +import com.devooks.backend.member.v1.dto.ModifyNicknameResponse +import com.devooks.backend.member.v1.dto.ModifyProfileCommand +import com.devooks.backend.member.v1.dto.ModifyProfileImageCommand +import com.devooks.backend.member.v1.dto.ModifyProfileImageRequest +import com.devooks.backend.member.v1.dto.ModifyProfileImageResponse +import com.devooks.backend.member.v1.dto.ModifyProfileRequest +import com.devooks.backend.member.v1.dto.ModifyProfileResponse +import com.devooks.backend.member.v1.dto.SignUpRequest +import com.devooks.backend.member.v1.dto.SignUpResponse +import com.devooks.backend.member.v1.dto.WithdrawMemberCommand +import com.devooks.backend.member.v1.dto.WithdrawMemberRequest +import com.devooks.backend.member.v1.dto.WithdrawMemberResponse +import com.devooks.backend.member.v1.service.FavoriteCategoryService +import com.devooks.backend.member.v1.service.MemberInfoService +import com.devooks.backend.member.v1.service.MemberService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import java.util.* +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "회원 API") +@RestController +@RequestMapping("/api/v1/members") +class MemberController( + private val memberService: MemberService, + private val memberInfoService: MemberInfoService, + private val categoryService: CategoryService, + private val favoriteCategoryService: FavoriteCategoryService, + private val tokenService: TokenService, +) { + + @Operation(summary = "회원가입") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "OK"), + ApiResponse( + responseCode = "400", + description = + "- AUTH-400-1 : 인증 코드(authorizationCode)가 NULL이거나 빈 문자일 경우\n" + + "- AUTH-400-2 : 인증 유형(oauthType)이 NAVER, KAKAO, GOOGLE 이 아닐 경우\n" + + "- MEMBER-400-1 : 닉네임(nickname)이 2~12 글자가 아닐 경우\n" + + "- MEMBER-400-2 : 관심 카테고리(favoriteCategories)가 NULL일 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "403", + description = + "- MEMBER-403-1 : 정지된 회원일 경우\n" + + "- MEMBER-403-2 : 탈퇴한 회원일 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ), + ApiResponse( + responseCode = "409", + description = "- MEMBER-409-1 : 닉네임이 이미 존재할 경우", + content = arrayOf(Content(schema = Schema(hidden = true))) + ) + ] + ) + @Transactional + @PostMapping("/signup") + suspend fun signUp( + @RequestBody + request: SignUpRequest, + ): SignUpResponse { + val command = request.toCommand() + val member = memberService.signUp(command) + memberInfoService.create(member) + val categories = categoryService.save(command.favoriteCategoryNames) + favoriteCategoryService.save(categories, member.id) + val tokenGroup = tokenService.createTokenGroup(member) + return SignUpResponse( + member = SignUpResponse.Member(member), + tokenGroup = tokenGroup + ) + } + + @Transactional + @PatchMapping("/account") + suspend fun modifyAccountInfo( + @RequestBody + request: ModifyAccountInfoRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): ModifyAccountInfoResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyAccountInfoCommand = request.toCommand() + val memberInfo: MemberInfo = memberInfoService.updateAccountInfo(command, requesterId) + return ModifyAccountInfoResponse(memberInfo) + } + + @Transactional + @PatchMapping("/image") + suspend fun modifyProfileImage( + @RequestBody + request: ModifyProfileImageRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): ModifyProfileImageResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyProfileImageCommand = request.toCommand() + val member: Member = memberService.updateProfileImage(command, requesterId) + return ModifyProfileImageResponse(member) + } + + @Transactional + @PatchMapping("/nickname") + suspend fun modifyNickname( + @RequestBody + request: ModifyNicknameRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): ModifyNicknameResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyNicknameCommand = request.toCommand() + val member: Member = memberService.updateNickname(command, requesterId) + return ModifyNicknameResponse(member) + } + + @Transactional + @PatchMapping("/profile") + suspend fun modifyProfile( + @RequestBody + request: ModifyProfileRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): ModifyProfileResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyProfileCommand = request.toCommand() + val memberInfo: MemberInfo = memberInfoService.updateProfile(command, requesterId) + val categories: List = categoryService.save(command.favoriteCategoryNames) + favoriteCategoryService.deleteByMemberId(requesterId) + favoriteCategoryService.save(categories, requesterId) + return ModifyProfileResponse(memberInfo, categories) + } + + @GetMapping("/{memberId}/profile") + suspend fun getProfile( + @PathVariable + memberId: UUID, + ): GetProfileResponse { + val member: Member = memberService.findById(memberId) + val memberInfo: MemberInfo = memberInfoService.findById(memberId) + val categories: List = favoriteCategoryService.findByMemberId(memberId) + return GetProfileResponse(member, memberInfo, categories) + } + + @Transactional + @PatchMapping("/withdrawal") + suspend fun withdrawMember( + @RequestBody + request: WithdrawMemberRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): WithdrawMemberResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command: WithdrawMemberCommand = request.toCommand() + memberService.withdraw(command, requesterId) + return WithdrawMemberResponse() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/domain/FavoriteCategory.kt b/src/main/kotlin/com/devooks/backend/member/v1/domain/FavoriteCategory.kt new file mode 100644 index 0000000..4f503a2 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/domain/FavoriteCategory.kt @@ -0,0 +1,15 @@ +package com.devooks.backend.member.v1.domain + +import com.devooks.backend.member.v1.entity.FavoriteCategoryEntity +import java.util.* + +class FavoriteCategory( + val id: UUID, + val categoryId: UUID, + val memberId: UUID +) { + + companion object { + fun FavoriteCategoryEntity.toDomain() = FavoriteCategory(id!!, categoryId, favoriteMemberId) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/domain/Member.kt b/src/main/kotlin/com/devooks/backend/member/v1/domain/Member.kt new file mode 100644 index 0000000..672accc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/domain/Member.kt @@ -0,0 +1,22 @@ +package com.devooks.backend.member.v1.domain + +import com.devooks.backend.auth.v1.domain.Authority +import com.devooks.backend.member.v1.entity.MemberEntity +import java.util.* + +class Member( + val id: UUID, + val nickname: String, + val profileImagePath: String, + val authority: Authority, +) { + companion object { + fun MemberEntity.toDomain(): Member = + Member( + id = this.id!!, + nickname = this.nickname, + profileImagePath = this.profileImagePath ?: "", + authority = this.authority + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/domain/MemberInfo.kt b/src/main/kotlin/com/devooks/backend/member/v1/domain/MemberInfo.kt new file mode 100644 index 0000000..1a649aa --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/domain/MemberInfo.kt @@ -0,0 +1,33 @@ +package com.devooks.backend.member.v1.domain + +import com.devooks.backend.member.v1.entity.MemberInfoEntity +import java.util.* + +class MemberInfo( + val id: UUID, + val memberId: UUID, + val blogLink: String, + val instagramLink: String, + val youtubeLink: String, + val realName: String, + val bank: String, + val accountNumber: String, + val introduction: String, + val phoneNumber: String, +) { + companion object { + fun MemberInfoEntity.toDomain(): MemberInfo = + MemberInfo( + id = this.id!!, + memberId = this.memberId, + blogLink = this.blogLink, + instagramLink = this.instagramLink, + youtubeLink = this.youtubeLink, + realName = this.realName, + bank = this.bank, + accountNumber = this.accountNumber, + introduction = this.introduction, + phoneNumber = this.phoneNumber, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/GetProfileResponse.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/GetProfileResponse.kt new file mode 100644 index 0000000..d672258 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/GetProfileResponse.kt @@ -0,0 +1,39 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.category.v1.domain.Category +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.MemberInfo +import java.util.* + +data class GetProfileResponse( + val memberId: UUID, + val nickname: String, + val profileImagePath: String, + val profile: Profile, + val favoriteCategories: List, +) { + + constructor( + member: Member, + memberInfo: MemberInfo, + categories: List, + ) : this( + memberId = member.id, + nickname = member.nickname, + profileImagePath = member.profileImagePath, + profile = Profile( + blogLink = memberInfo.blogLink, + instagramLink = memberInfo.instagramLink, + youtubeLink = memberInfo.youtubeLink, + introduction = memberInfo.introduction + ), + favoriteCategories = categories.map { it.name } + ) + + data class Profile( + val blogLink: String, + val instagramLink: String, + val youtubeLink: String, + val introduction: String, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyAccountInfoCommand.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyAccountInfoCommand.kt new file mode 100644 index 0000000..92fa471 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyAccountInfoCommand.kt @@ -0,0 +1,7 @@ +package com.devooks.backend.member.v1.dto + +class ModifyAccountInfoCommand( + val realName: String, + val bank: String, + val accountNumber: String, +) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyAccountInfoRequest.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyAccountInfoRequest.kt new file mode 100644 index 0000000..58b2adc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyAccountInfoRequest.kt @@ -0,0 +1,20 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.member.v1.error.validateAccountNumber +import com.devooks.backend.member.v1.error.validateBank +import com.devooks.backend.member.v1.error.validateRealName + +data class ModifyAccountInfoRequest( + val realName: String?, + val bank: String?, + val accountNumber: String?, +) { + + fun toCommand(): ModifyAccountInfoCommand = + ModifyAccountInfoCommand( + realName = realName.validateRealName(), + bank = bank.validateBank(), + accountNumber = accountNumber.validateAccountNumber() + ) + +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyAccountInfoResponse.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyAccountInfoResponse.kt new file mode 100644 index 0000000..6821945 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyAccountInfoResponse.kt @@ -0,0 +1,19 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.member.v1.domain.MemberInfo + +data class ModifyAccountInfoResponse( + val realName: String, + val bank: String, + val accountNumber: String, +) { + + constructor( + memberInfo: MemberInfo, + ) : this( + realName = memberInfo.realName, + bank = memberInfo.bank, + accountNumber = memberInfo.accountNumber, + ) + +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyNicknameCommand.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyNicknameCommand.kt new file mode 100644 index 0000000..5388d4d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyNicknameCommand.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.member.v1.dto + +class ModifyNicknameCommand( + val nickname: String +) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyNicknameRequest.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyNicknameRequest.kt new file mode 100644 index 0000000..2a1f6fb --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyNicknameRequest.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.member.v1.error.validateNickname + +data class ModifyNicknameRequest( + val nickname: String? +) { + fun toCommand(): ModifyNicknameCommand = + ModifyNicknameCommand( + nickname = nickname.validateNickname() + ) +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyNicknameResponse.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyNicknameResponse.kt new file mode 100644 index 0000000..5b55d3f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyNicknameResponse.kt @@ -0,0 +1,7 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.member.v1.domain.Member + +data class ModifyNicknameResponse( + val member: Member +) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileCommand.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileCommand.kt new file mode 100644 index 0000000..e5d86fc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileCommand.kt @@ -0,0 +1,10 @@ +package com.devooks.backend.member.v1.dto + +class ModifyProfileCommand( + val phoneNumber: String, + val blogLink: String, + val instagramLink: String, + val youtubeLink: String, + val introduction: String, + val favoriteCategoryNames: List, +) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileImageCommand.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileImageCommand.kt new file mode 100644 index 0000000..1e71081 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileImageCommand.kt @@ -0,0 +1,7 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.common.domain.Image + +class ModifyProfileImageCommand( + val image: Image +) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileImageRequest.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileImageRequest.kt new file mode 100644 index 0000000..db0a3f1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileImageRequest.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.common.error.validateImage + +data class ModifyProfileImageRequest( + val image: ImageDto? +) { + fun toCommand(): ModifyProfileImageCommand = + ModifyProfileImageCommand( + image = image.validateImage() + ) +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileImageResponse.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileImageResponse.kt new file mode 100644 index 0000000..6032d97 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileImageResponse.kt @@ -0,0 +1,7 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.member.v1.domain.Member + +data class ModifyProfileImageResponse( + val member: Member +) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileRequest.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileRequest.kt new file mode 100644 index 0000000..f617c37 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileRequest.kt @@ -0,0 +1,27 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.member.v1.error.validateBlogLink +import com.devooks.backend.member.v1.error.validateFavoriteCategoryNames +import com.devooks.backend.member.v1.error.validateInstagramLink +import com.devooks.backend.member.v1.error.validateIntroduction +import com.devooks.backend.member.v1.error.validatePhoneNumber +import com.devooks.backend.member.v1.error.validateYoutubeLink + +data class ModifyProfileRequest( + val phoneNumber: String?, + val blogLink: String?, + val instagramLink: String?, + val youtubeLink: String?, + val introduction: String?, + val favoriteCategoryNames: List?, +) { + fun toCommand(): ModifyProfileCommand = + ModifyProfileCommand( + phoneNumber = phoneNumber.validatePhoneNumber(), + blogLink = blogLink.validateBlogLink(), + instagramLink = instagramLink.validateInstagramLink(), + youtubeLink = youtubeLink.validateYoutubeLink(), + introduction = introduction.validateIntroduction(), + favoriteCategoryNames = favoriteCategoryNames.validateFavoriteCategoryNames() + ) +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileResponse.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileResponse.kt new file mode 100644 index 0000000..f6b94f4 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/ModifyProfileResponse.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.category.v1.domain.Category +import com.devooks.backend.member.v1.domain.MemberInfo + +data class ModifyProfileResponse( + val memberInfo: MemberInfo, + val favoriteCategories: List, +) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/SignUpCommand.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/SignUpCommand.kt new file mode 100644 index 0000000..50e9c64 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/SignUpCommand.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.auth.v1.domain.OauthId +import com.devooks.backend.auth.v1.domain.OauthType + +class SignUpCommand( + val oauthId: OauthId, + val oauthType: OauthType, + val nickname: String, + val favoriteCategoryNames: List, +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/SignUpRequest.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/SignUpRequest.kt new file mode 100644 index 0000000..e9bb35d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/SignUpRequest.kt @@ -0,0 +1,28 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.auth.v1.error.validateOauthId +import com.devooks.backend.auth.v1.error.validateOauthType +import com.devooks.backend.member.v1.error.validateFavoriteCategories +import com.devooks.backend.member.v1.error.validateNickname +import io.swagger.v3.oas.annotations.media.Schema + +data class SignUpRequest( + @Schema(description = "OAuth2 식별자", required = true, nullable = false) + val oauthId: String?, + @Schema(description = "OAuth2 인증 유형", required = true, nullable = false, example = "NAVER") + val oauthType: String?, + @Schema(description = "닉네임", required = true, nullable = false) + val nickname: String?, + @Schema(description = "관심 카테고리 목록", required = true, nullable = false) + val favoriteCategories: List?, +) { + + fun toCommand(): SignUpCommand = + SignUpCommand( + oauthId = oauthId.validateOauthId(), + oauthType = oauthType.validateOauthType(), + nickname = nickname.validateNickname(), + favoriteCategoryNames = favoriteCategories.validateFavoriteCategories() + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/SignUpResponse.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/SignUpResponse.kt new file mode 100644 index 0000000..bb9ca26 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/SignUpResponse.kt @@ -0,0 +1,26 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.auth.v1.domain.Authority +import com.devooks.backend.auth.v1.domain.TokenGroup +import java.util.* + +data class SignUpResponse( + val member: Member, + val tokenGroup: TokenGroup, +) { + data class Member( + val id: UUID, + val nickname: String, + val profileImagePath: String, + val authority: Authority, + ) { + constructor( + member: com.devooks.backend.member.v1.domain.Member, + ) : this( + id = member.id, + nickname = member.nickname, + profileImagePath = member.profileImagePath, + authority = member.authority + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/WithdrawMemberCommand.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/WithdrawMemberCommand.kt new file mode 100644 index 0000000..0846a28 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/WithdrawMemberCommand.kt @@ -0,0 +1,3 @@ +package com.devooks.backend.member.v1.dto + +data class WithdrawMemberCommand(val withdrawalReason: String) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/WithdrawMemberRequest.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/WithdrawMemberRequest.kt new file mode 100644 index 0000000..8c295f6 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/WithdrawMemberRequest.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.member.v1.dto + +import com.devooks.backend.member.v1.error.validateWithdrawalReason + +data class WithdrawMemberRequest( + val withdrawalReason: String? +) { + + fun toCommand(): WithdrawMemberCommand = + WithdrawMemberCommand( + withdrawalReason = withdrawalReason.validateWithdrawalReason() + ) +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/dto/WithdrawMemberResponse.kt b/src/main/kotlin/com/devooks/backend/member/v1/dto/WithdrawMemberResponse.kt new file mode 100644 index 0000000..28a82b3 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/dto/WithdrawMemberResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.member.v1.dto + +data class WithdrawMemberResponse( + val message: String = "탈퇴가 완료됐습니다." +) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/entity/FavoriteCategoryEntity.kt b/src/main/kotlin/com/devooks/backend/member/v1/entity/FavoriteCategoryEntity.kt new file mode 100644 index 0000000..66504e2 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/entity/FavoriteCategoryEntity.kt @@ -0,0 +1,21 @@ +package com.devooks.backend.member.v1.entity + +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "favorite_category") +data class FavoriteCategoryEntity( + @Id + @Column("favorite_category_id") + @get:JvmName("favoriteCategoryId") + val id: UUID? = null, + val favoriteMemberId: UUID, + val categoryId: UUID, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/entity/MemberEntity.kt b/src/main/kotlin/com/devooks/backend/member/v1/entity/MemberEntity.kt new file mode 100644 index 0000000..2ca1a08 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/entity/MemberEntity.kt @@ -0,0 +1,28 @@ +package com.devooks.backend.member.v1.entity + +import com.devooks.backend.auth.v1.domain.Authority +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "member") +data class MemberEntity( + @Id + @Column("member_id") + @get:JvmName("memberId") + val id: UUID? = null, + val profileImagePath: String? = null, + val nickname: String, + val authority: Authority = Authority.USER, + val withdrawalDate: Instant? = null, + val untilSuspensionDate: Instant? = null, + val registeredDate: Instant = Instant.now(), + val modifiedDate: Instant = registeredDate, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/entity/MemberInfoEntity.kt b/src/main/kotlin/com/devooks/backend/member/v1/entity/MemberInfoEntity.kt new file mode 100644 index 0000000..37e9af9 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/entity/MemberInfoEntity.kt @@ -0,0 +1,28 @@ +package com.devooks.backend.member.v1.entity + +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "member_info") +data class MemberInfoEntity( + @Id + @Column("member_info_id") + @get:JvmName("memberInfoId") + val id: UUID? = null, + val memberId: UUID, + val blogLink: String = "", + val instagramLink: String = "", + val youtubeLink: String = "", + val realName: String = "", + val bank: String = "", + val accountNumber: String = "", + val introduction: String = "", + val phoneNumber: String = "", +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/error/MemberError.kt b/src/main/kotlin/com/devooks/backend/member/v1/error/MemberError.kt new file mode 100644 index 0000000..3b65107 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/error/MemberError.kt @@ -0,0 +1,43 @@ +package com.devooks.backend.member.v1.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.CONFLICT +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.NOT_FOUND + +enum class MemberError(val exception: GeneralException) { + // 400 + REQUIRED_NICKNAME(GeneralException("MEMBER-400-1", BAD_REQUEST, "닉네임이 반드시 필요합니다.")), + REQUIRED_FAVORITE_CATEGORIES(GeneralException("MEMBER-400-2", BAD_REQUEST, "관심 카테고리 목록이 반드시 필요합니다.")), + INVALID_NICKNAME(GeneralException("MEMBER-400-3", BAD_REQUEST, "닉네임은 2자 이상 12자 이하만 가능합니다.")), + REQUIRED_REAL_NAME(GeneralException("MEMBER-400-4", BAD_REQUEST, "이름이 반드시 필요합니다.")), + REQUIRED_BANK(GeneralException("MEMBER-400-5", BAD_REQUEST, "은행이 반드시 필요합니다.")), + REQUIRED_ACCOUNT_NUMBER(GeneralException("MEMBER-400-6", BAD_REQUEST, "계좌번호가 반드시 필요합니다.")), + REQUIRED_PHONE_NUMBER(GeneralException("MEMBER-400-7", BAD_REQUEST, "전화번호가 반드시 필요합니다.")), + INVALID_PHONE_NUMBER(GeneralException("MEMBER-400-8", BAD_REQUEST, "잘못된 형식의 전화번호 입니다.")), + REQUIRED_BLOG_LINK(GeneralException("MEMBER-400-9", BAD_REQUEST, "블로그 링크가 반드시 필요합니다.")), + REQUIRED_INSTAGRAM_LINK(GeneralException("MEMBER-400-10", BAD_REQUEST, "인스타그램 링크가 반드시 필요합니다.")), + REQUIRED_YOUTUBE_LINK(GeneralException("MEMBER-400-11", BAD_REQUEST, "유튜브 링크가 반드시 필요합니다.")), + REQUIRED_INTRODUCTION_LINK(GeneralException("MEMBER-400-12", BAD_REQUEST, "소개글이 반드시 필요합니다.")), + INVALID_FAVORITE_CATEGORIES(GeneralException("MEMBER-400-13", BAD_REQUEST, "카테고리는 20자 이하만 가능합니다.")), + REQUIRED_WITHDRAWAL_REASON(GeneralException("MEMBER-400-14", BAD_REQUEST, "탈퇴 이유가 반드시 필요합니다.")), + INVALID_MEMBER_ID(GeneralException("MEMBER-400-15", BAD_REQUEST, "잘못된 형식의 회원 식별자 입니다.")), + + // 403 + SUSPENDED_MEMBER(GeneralException("MEMBER-403-1", FORBIDDEN, "정지된 회원으로 서비스 이용이 불가합니다.")), + WITHDREW_MEMBER(GeneralException("MEMBER-403-2", FORBIDDEN, "탈퇴한 회원으로 계정 복구가 필요합니다.")), + + // 404 + NOT_FOUND_OAUTH_INFO_BY_EMAIL(GeneralException("MEMBER-404-1", NOT_FOUND, "")), + NOT_FOUND_MEMBER_INFO_BY_ID(GeneralException("MEMBER-404-2", NOT_FOUND, "회원 정보를 찾을 수 없습니다.")), + NOT_FOUND_MEMBER_BY_ID(GeneralException("MEMBER-404-3", NOT_FOUND, "회원을 찾을 수 없습니다.")), + + // 409 + DUPLICATE_NICKNAME(GeneralException("MEMBER-409-1", CONFLICT, "닉네임이 이미 존재합니다.")), + ; + + override fun toString(): String { + return "MemberError(exception=$exception)" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/error/MemberValidation.kt b/src/main/kotlin/com/devooks/backend/member/v1/error/MemberValidation.kt new file mode 100644 index 0000000..63446c1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/error/MemberValidation.kt @@ -0,0 +1,74 @@ +package com.devooks.backend.member.v1.error + +import com.devooks.backend.common.error.validateUUID +import com.devooks.backend.common.error.validateNotBlank +import com.devooks.backend.common.error.validateNotNull +import java.util.* + +private val phoneRegex = Regex("^[0-9]{2,3}-[0-9]{3,4}-[0-9]{3,4}$") + +fun String?.validateNickname(): String = + validateNotBlank(MemberError.REQUIRED_NICKNAME.exception) + .also { + it.takeIf { it.length in 2..12 } + ?: throw MemberError.INVALID_NICKNAME.exception + } + +fun List?.validateFavoriteCategories(): List = + validateNotNull(MemberError.REQUIRED_FAVORITE_CATEGORIES.exception) + +fun String?.validateRealName(): String = + validateNotBlank(MemberError.REQUIRED_REAL_NAME.exception) + +fun String?.validateBank(): String = + validateNotBlank(MemberError.REQUIRED_BANK.exception) + +fun String?.validateAccountNumber(): String = + validateNotBlank(MemberError.REQUIRED_ACCOUNT_NUMBER.exception) + +fun String?.validatePhoneNumber(): String = + validateNotNull(MemberError.REQUIRED_PHONE_NUMBER.exception) + .takeIf { it.isNotBlank() } + ?.also { + it.takeIf { phoneRegex.matches(it) } + ?: throw MemberError.INVALID_PHONE_NUMBER.exception + } + ?: "" + +fun String?.validateBlogLink(): String = + validateNotNull(MemberError.REQUIRED_BLOG_LINK.exception) + .takeIf { it.isNotBlank() } + ?: "" + +fun String?.validateInstagramLink(): String = + validateNotNull(MemberError.REQUIRED_INSTAGRAM_LINK.exception) + .takeIf { it.isNotBlank() } + ?: "" + +fun String?.validateYoutubeLink(): String = + validateNotNull(MemberError.REQUIRED_YOUTUBE_LINK.exception) + .takeIf { it.isNotBlank() } + ?: "" + +fun String?.validateIntroduction(): String = + validateNotNull(MemberError.REQUIRED_INTRODUCTION_LINK.exception) + .takeIf { it.isNotBlank() } + ?: "" + +fun List?.validateFavoriteCategoryNames(): List = + validateNotNull(MemberError.REQUIRED_FAVORITE_CATEGORIES.exception) + .mapNotNull { category -> + category + .ifBlank { null } + .also { + it?.takeIf { it.length in 1..20 } + ?: throw MemberError.INVALID_FAVORITE_CATEGORIES.exception + } + } + .distinct() + +fun String?.validateWithdrawalReason(): String = + validateNotBlank(MemberError.REQUIRED_WITHDRAWAL_REASON.exception) + +fun String?.validateMemberId(): UUID = + validateUUID(MemberError.INVALID_MEMBER_ID.exception) diff --git a/src/main/kotlin/com/devooks/backend/member/v1/repository/FavoriteCategoryRepository.kt b/src/main/kotlin/com/devooks/backend/member/v1/repository/FavoriteCategoryRepository.kt new file mode 100644 index 0000000..9503688 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/repository/FavoriteCategoryRepository.kt @@ -0,0 +1,22 @@ +package com.devooks.backend.member.v1.repository + +import com.devooks.backend.category.v1.entity.CategoryEntity +import com.devooks.backend.member.v1.entity.FavoriteCategoryEntity +import java.util.* +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface FavoriteCategoryRepository : CoroutineCrudRepository { + + suspend fun deleteAllByFavoriteMemberId(memberId: UUID) + + @Query(""" + SELECT c.* + FROM favorite_category f, category c + WHERE f.category_id = c.category_id + AND f.favorite_member_id = :memberId + """) + suspend fun findAllByFavoriteMemberId(memberId: UUID): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/repository/MemberInfoRepository.kt b/src/main/kotlin/com/devooks/backend/member/v1/repository/MemberInfoRepository.kt new file mode 100644 index 0000000..2f3e813 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/repository/MemberInfoRepository.kt @@ -0,0 +1,10 @@ +package com.devooks.backend.member.v1.repository + +import com.devooks.backend.member.v1.entity.MemberInfoEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository + +interface MemberInfoRepository : CoroutineCrudRepository { + + suspend fun findByMemberId(memberId: UUID): MemberInfoEntity? +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/repository/MemberRepository.kt b/src/main/kotlin/com/devooks/backend/member/v1/repository/MemberRepository.kt new file mode 100644 index 0000000..2346497 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/repository/MemberRepository.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.member.v1.repository + +import com.devooks.backend.member.v1.entity.MemberEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface MemberRepository : CoroutineCrudRepository { + + suspend fun findByNickname(nickname: String): MemberEntity? +} diff --git a/src/main/kotlin/com/devooks/backend/member/v1/service/FavoriteCategoryService.kt b/src/main/kotlin/com/devooks/backend/member/v1/service/FavoriteCategoryService.kt new file mode 100644 index 0000000..9f2643c --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/service/FavoriteCategoryService.kt @@ -0,0 +1,37 @@ +package com.devooks.backend.member.v1.service + +import com.devooks.backend.category.v1.domain.Category +import com.devooks.backend.category.v1.domain.Category.Companion.toDomain +import com.devooks.backend.member.v1.domain.FavoriteCategory +import com.devooks.backend.member.v1.domain.FavoriteCategory.Companion.toDomain +import com.devooks.backend.member.v1.entity.FavoriteCategoryEntity +import com.devooks.backend.member.v1.repository.FavoriteCategoryRepository +import java.util.* +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +@Service +class FavoriteCategoryService( + private val favoriteCategoryRepository: FavoriteCategoryRepository, +) { + + suspend fun save(categories: List, memberId: UUID): List = + categories + .asFlow() + .map { FavoriteCategoryEntity(favoriteMemberId = memberId, categoryId = it.id) } + .map { favoriteCategoryRepository.save(it) } + .map { it.toDomain() } + .toList() + + suspend fun deleteByMemberId(memberId: UUID) { + favoriteCategoryRepository.deleteAllByFavoriteMemberId(memberId) + } + + suspend fun findByMemberId(memberId: UUID): List = + favoriteCategoryRepository + .findAllByFavoriteMemberId(memberId) + .map { it.toDomain() } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/service/MemberInfoService.kt b/src/main/kotlin/com/devooks/backend/member/v1/service/MemberInfoService.kt new file mode 100644 index 0000000..7b2d13f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/service/MemberInfoService.kt @@ -0,0 +1,63 @@ +package com.devooks.backend.member.v1.service + +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.MemberInfo +import com.devooks.backend.member.v1.domain.MemberInfo.Companion.toDomain +import com.devooks.backend.member.v1.dto.ModifyAccountInfoCommand +import com.devooks.backend.member.v1.dto.ModifyProfileCommand +import com.devooks.backend.member.v1.entity.MemberInfoEntity +import com.devooks.backend.member.v1.error.MemberError +import com.devooks.backend.member.v1.repository.MemberInfoRepository +import java.util.* +import org.springframework.stereotype.Service + +@Service +class MemberInfoService( + private val memberInfoRepository: MemberInfoRepository +) { + + suspend fun create(member: Member): MemberInfo { + val entity = MemberInfoEntity(memberId = member.id) + val memberInfo = memberInfoRepository.save(entity).toDomain() + return memberInfo + } + + suspend fun updateAccountInfo( + command: ModifyAccountInfoCommand, + requesterId: UUID, + ): MemberInfo { + val memberInfo = + memberInfoRepository + .findByMemberId(requesterId) + ?: throw MemberError.NOT_FOUND_MEMBER_INFO_BY_ID.exception + val updatedMemberInfo = + memberInfo.copy(accountNumber = command.accountNumber, bank = command.bank, realName = command.realName) + val savedMemberInfo = memberInfoRepository.save(updatedMemberInfo) + return savedMemberInfo.toDomain() + } + + suspend fun findById(memberId: UUID): MemberInfo = + findMemberInfoById(memberId).toDomain() + + suspend fun updateProfile( + command: ModifyProfileCommand, + requesterId: UUID, + ): MemberInfo { + val memberInfo = findMemberInfoById(requesterId) + val updateMemberInfo = memberInfo + .copy( + phoneNumber = command.phoneNumber, + blogLink = command.blogLink, + instagramLink = command.instagramLink, + youtubeLink = command.youtubeLink, + introduction = command.introduction + ) + return memberInfoRepository.save(updateMemberInfo).toDomain() + } + + private suspend fun findMemberInfoById(requesterId: UUID) = + memberInfoRepository + .findByMemberId(requesterId) + ?: throw MemberError.NOT_FOUND_MEMBER_INFO_BY_ID.exception + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/member/v1/service/MemberService.kt b/src/main/kotlin/com/devooks/backend/member/v1/service/MemberService.kt new file mode 100644 index 0000000..7c056eb --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/member/v1/service/MemberService.kt @@ -0,0 +1,123 @@ +package com.devooks.backend.member.v1.service + +import com.devooks.backend.BackendApplication.Companion.PROFILE_IMAGE_ROOT_PATH +import com.devooks.backend.auth.v1.domain.OauthInfo +import com.devooks.backend.auth.v1.domain.OauthType +import com.devooks.backend.auth.v1.entity.OauthInfoEntity +import com.devooks.backend.auth.v1.error.AuthError +import com.devooks.backend.auth.v1.repository.OauthInfoRepository +import com.devooks.backend.common.utils.saveImage +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.dto.ModifyNicknameCommand +import com.devooks.backend.member.v1.dto.ModifyProfileImageCommand +import com.devooks.backend.member.v1.dto.SignUpCommand +import com.devooks.backend.member.v1.dto.WithdrawMemberCommand +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.error.MemberError +import com.devooks.backend.member.v1.repository.MemberRepository +import java.time.Instant +import java.util.* +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class MemberService( + private val memberRepository: MemberRepository, + private val oauthInfoRepository: OauthInfoRepository, +) { + + @Transactional + suspend fun signUp(command: SignUpCommand): Member { + validateOauthInfo(command.oauthId, command.oauthType) + validateNickname(command.nickname) + val member = MemberEntity(nickname = command.nickname) + val savedMember = memberRepository.save(member) + val oauthInfo = OauthInfoEntity( + oauthId = command.oauthId, + oauthType = command.oauthType, + memberId = savedMember.id!! + ) + oauthInfoRepository.save(oauthInfo) + return savedMember.toDomain() + } + + suspend fun findByOauthInfo(oauthInfo: OauthInfo): Member { + val foundOauthInfo = oauthInfoRepository + .findByOauthIdAndOauthType(oauthInfo.oauthId, oauthInfo.oauthType) + ?: throw MemberError + .NOT_FOUND_OAUTH_INFO_BY_EMAIL + .exception + .copy(message = "{\"oauthId\":\"${oauthInfo.oauthId}\"}") + + return memberRepository + .findById(foundOauthInfo.memberId) + ?.also { member -> validateMember(member) } + ?.toDomain() + ?: throw MemberError + .NOT_FOUND_OAUTH_INFO_BY_EMAIL + .exception + .copy(message = "{\"oauthId\":\"${foundOauthInfo.oauthId}\"}") + } + + suspend fun updateProfileImage( + command: ModifyProfileImageCommand, + requesterId: UUID, + ): Member { + val member = findMemberById(requesterId) + val image = command.image + val path = saveImage(image, PROFILE_IMAGE_ROOT_PATH) + val updatedMember = member.copy(profileImagePath = path) + return memberRepository.save(updatedMember).toDomain() + } + + suspend fun updateNickname( + command: ModifyNicknameCommand, + requesterId: UUID, + ): Member { + validateNickname(command.nickname) + val member = findMemberById(requesterId) + val updatedMember = member.copy(nickname = command.nickname) + return memberRepository.save(updatedMember).toDomain() + } + + suspend fun findById(memberId: UUID): Member = findMemberById(memberId).toDomain() + + suspend fun withdraw(command: WithdrawMemberCommand, requesterId: UUID) { + findMemberById(requesterId) + .copy(withdrawalDate = Instant.now()) + .also { updateMember -> memberRepository.save(updateMember) } + } + + private suspend fun validateOauthInfo(oauthId: String, oauthType: OauthType) { + oauthInfoRepository + .findByOauthIdAndOauthType(oauthId, oauthType) + ?.let { oauthInfo -> + val member = findMemberById(oauthInfo.memberId) + validateMember(member) + throw AuthError.DUPLICATE_OAUTH_ID.exception + } + } + + private suspend fun validateMember(member: MemberEntity) { + val now = Instant.now() + + if (member.untilSuspensionDate != null && now.isBefore(member.untilSuspensionDate)) { + throw MemberError.SUSPENDED_MEMBER.exception + } else if (member.withdrawalDate != null) { + throw MemberError.WITHDREW_MEMBER.exception + } + } + + private suspend fun findMemberById(requesterId: UUID) = + memberRepository + .findById(requesterId) + ?: throw MemberError.NOT_FOUND_MEMBER_BY_ID.exception + + private suspend fun validateNickname(nickname: String) { + memberRepository + .findByNickname(nickname) + ?.let { throw MemberError.DUPLICATE_NICKNAME.exception } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/CheckNotificationResponse.kt b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/CheckNotificationResponse.kt new file mode 100644 index 0000000..a56b581 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/CheckNotificationResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.notification.v1.adapter.`in`.dto + +data class CheckNotificationResponse( + val count: Int, +) diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/CheckNotificationsRequest.kt b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/CheckNotificationsRequest.kt new file mode 100644 index 0000000..d80bf32 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/CheckNotificationsRequest.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.notification.v1.adapter.`in`.dto + +import com.devooks.backend.notification.v1.error.validateNotificationId +import java.util.* + +data class CheckNotificationsRequest( + val memberId: UUID, + val notificationId: UUID? +) { + constructor( + memberId: UUID, + notificationId: String? + ): this( + memberId = memberId, + notificationId = notificationId?.validateNotificationId() + ) +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/CreateNotificationRequest.kt b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/CreateNotificationRequest.kt new file mode 100644 index 0000000..6c93606 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/CreateNotificationRequest.kt @@ -0,0 +1,24 @@ +package com.devooks.backend.notification.v1.adapter.`in`.dto + +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.notification.v1.domain.event.CreateNotificationEvent +import com.devooks.backend.notification.v1.domain.event.NotificationContent +import com.devooks.backend.notification.v1.domain.event.NotificationNote +import java.util.* + +data class CreateNotificationRequest( + val content: NotificationContent, + val note: NotificationNote, + val receiverId: UUID, + val type: NotificationType, +) { + companion object { + fun CreateNotificationEvent.toCreateNotificationRequest() = + CreateNotificationRequest( + content = content, + note = createNote(), + receiverId = receiverId, + type = notificationType + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/GetNotificationsRequest.kt b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/GetNotificationsRequest.kt new file mode 100644 index 0000000..f26cfb8 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/GetNotificationsRequest.kt @@ -0,0 +1,18 @@ +package com.devooks.backend.notification.v1.adapter.`in`.dto + +import com.devooks.backend.common.dto.Paging +import java.util.* +import org.springframework.data.domain.Pageable + +data class GetNotificationsRequest( + val memberId: UUID, + private val paging: Paging, +) { + constructor( + memberId: UUID, + page: String, + count: String, + ) : this(memberId, Paging(page, count)) + + val pageable: Pageable = paging.value +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/NotificationResponse.kt b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/NotificationResponse.kt new file mode 100644 index 0000000..647e878 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/NotificationResponse.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.notification.v1.adapter.`in`.dto + +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.notification.v1.domain.event.NotificationContent +import com.devooks.backend.notification.v1.domain.event.NotificationNote +import java.time.Instant +import java.util.* + +data class NotificationResponse( + val id: UUID, + val type: NotificationType, + val content: NotificationContent, + val note: NotificationNote, + val receiverId: UUID, + val notifiedDate: Instant, + val checked: Boolean, +) diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/StreamCountResponse.kt b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/StreamCountResponse.kt new file mode 100644 index 0000000..e937e40 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/dto/StreamCountResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.notification.v1.adapter.`in`.dto + +data class StreamCountResponse( + val countOfUncheckedNotification: Long, +) diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/event/NotificationEventListener.kt b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/event/NotificationEventListener.kt new file mode 100644 index 0000000..dd95a34 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/event/NotificationEventListener.kt @@ -0,0 +1,32 @@ +package com.devooks.backend.notification.v1.adapter.`in`.event + +import com.devooks.backend.common.utils.logger +import com.devooks.backend.notification.v1.adapter.`in`.dto.CreateNotificationRequest +import com.devooks.backend.notification.v1.adapter.`in`.dto.CreateNotificationRequest.Companion.toCreateNotificationRequest +import com.devooks.backend.notification.v1.application.port.`in`.CreateNotificationUseCase +import com.devooks.backend.notification.v1.domain.event.CreateNotificationEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +@Component +class NotificationEventListener( + private val createNotificationUseCase: CreateNotificationUseCase, +) { + private val logger = logger() + + @EventListener + fun consumeCreateDomainEvent(event: CreateNotificationEvent) { + CoroutineScope(Dispatchers.IO).launch { + runCatching { + val request: CreateNotificationRequest = event.toCreateNotificationRequest() + createNotificationUseCase.create(request) + }.onFailure { + logger.error("알림 생성을 실패했습니다 [ event : ${event.createNote()} ]") + logger.error(it.stackTraceToString()) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/http/NotificationRouter.kt b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/http/NotificationRouter.kt new file mode 100644 index 0000000..e7ac9c9 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/adapter/in/http/NotificationRouter.kt @@ -0,0 +1,96 @@ +package com.devooks.backend.notification.v1.adapter.`in`.http + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.PageResponse +import com.devooks.backend.common.dto.PageResponse.Companion.toResponse +import com.devooks.backend.notification.v1.adapter.`in`.dto.CheckNotificationResponse +import com.devooks.backend.notification.v1.adapter.`in`.dto.CheckNotificationsRequest +import com.devooks.backend.notification.v1.adapter.`in`.dto.GetNotificationsRequest +import com.devooks.backend.notification.v1.adapter.`in`.dto.NotificationResponse +import com.devooks.backend.notification.v1.adapter.`in`.dto.StreamCountResponse +import com.devooks.backend.notification.v1.application.port.`in`.GetNotificationUseCase +import com.devooks.backend.notification.v1.application.port.out.ModifyNotificationUseCase +import java.time.Duration +import java.util.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.reactive.asFlow +import org.springframework.data.domain.Page +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.codec.ServerSentEvent +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Flux.interval + +@RestController +@RequestMapping("/api/v1/notifications") +class NotificationRouter( + private val tokenService: TokenService, + private val getNotificationUseCase: GetNotificationUseCase, + private val modifyNotificationUseCase: ModifyNotificationUseCase, +) { + + @GetMapping("/count") + suspend fun streamCountOfUncheckedNotifications( + @RequestHeader(AUTHORIZATION) + authorization: String, + ): Flow> = + tokenService + .getMemberId(Authorization(authorization)) + .let { memberId -> + interval(streamIntervalDuration) + .asFlow() + .map { getCountOfUncheckedNotifications(memberId) } + } + + @GetMapping + suspend fun getNotifications( + @RequestHeader(AUTHORIZATION) + authorization: String, + @RequestParam(name = "page", defaultValue = "1") + page: String, + @RequestParam(name = "count", defaultValue = "10") + count: String, + ): PageResponse { + val memberId = tokenService.getMemberId(Authorization(authorization)) + val request = GetNotificationsRequest(memberId, page, count) + val notifications: Page = getNotificationUseCase.getNotifications(request) + return notifications.toResponse() + } + + @PatchMapping(path = ["/{notificationId}/checked", "/checked"]) + suspend fun checkNotifications( + @RequestHeader(AUTHORIZATION) + authorization: String, + @PathVariable("notificationId", required = false) + notificationId: String?, + ): CheckNotificationResponse { + val memberId = tokenService.getMemberId(Authorization(authorization)) + val request = CheckNotificationsRequest(memberId, notificationId) + val size: Int = modifyNotificationUseCase.check(request) + return CheckNotificationResponse(size) + } + + private suspend fun getCountOfUncheckedNotifications(memberId: UUID): ServerSentEvent { + val size: Long = getNotificationUseCase.getCountOfUnchecked(memberId) + val response = StreamCountResponse(size) + return ServerSentEvent + .builder() + .id(memberId.toString()) + .event("streamCountOfUncheckedNotifications") + .retry(retryDuration) + .data(response) + .build() + } + + companion object { + private val streamIntervalDuration = Duration.ofSeconds(1) + private val retryDuration = Duration.ofSeconds(10) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/application/port/in/CreateNotificationUseCase.kt b/src/main/kotlin/com/devooks/backend/notification/v1/application/port/in/CreateNotificationUseCase.kt new file mode 100644 index 0000000..f47ef4e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/application/port/in/CreateNotificationUseCase.kt @@ -0,0 +1,8 @@ +package com.devooks.backend.notification.v1.application.port.`in` + +import com.devooks.backend.notification.v1.adapter.`in`.dto.CreateNotificationRequest +import com.devooks.backend.notification.v1.domain.Notification + +interface CreateNotificationUseCase { + suspend fun create(request: CreateNotificationRequest): Notification +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/application/port/in/GetNotificationUseCase.kt b/src/main/kotlin/com/devooks/backend/notification/v1/application/port/in/GetNotificationUseCase.kt new file mode 100644 index 0000000..39cb1de --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/application/port/in/GetNotificationUseCase.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.notification.v1.application.port.`in` + +import com.devooks.backend.notification.v1.adapter.`in`.dto.GetNotificationsRequest +import com.devooks.backend.notification.v1.adapter.`in`.dto.NotificationResponse +import java.util.* +import org.springframework.data.domain.Page + +interface GetNotificationUseCase { + suspend fun getCountOfUnchecked(memberId: UUID): Long + suspend fun getNotifications(request: GetNotificationsRequest): Page +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/application/service/NotificationCommandService.kt b/src/main/kotlin/com/devooks/backend/notification/v1/application/service/NotificationCommandService.kt new file mode 100644 index 0000000..a1077ea --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/application/service/NotificationCommandService.kt @@ -0,0 +1,19 @@ +package com.devooks.backend.notification.v1.application.service + +import com.devooks.backend.notification.v1.adapter.`in`.dto.CreateNotificationRequest +import com.devooks.backend.notification.v1.application.port.`in`.CreateNotificationUseCase +import com.devooks.backend.notification.v1.application.port.out.SaveNotificationPort +import com.devooks.backend.notification.v1.domain.Notification +import com.devooks.backend.notification.v1.domain.Notification.Companion.toDomain +import org.springframework.stereotype.Service + +@Service +class NotificationCommandService( + private val saveNotificationPort: SaveNotificationPort +): CreateNotificationUseCase { + + override suspend fun create(request: CreateNotificationRequest): Notification { + val notification: Notification = request.toDomain() + return saveNotificationPort.save(notification) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/application/service/NotificationQueryService.kt b/src/main/kotlin/com/devooks/backend/notification/v1/application/service/NotificationQueryService.kt new file mode 100644 index 0000000..554887d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/application/service/NotificationQueryService.kt @@ -0,0 +1,27 @@ +package com.devooks.backend.notification.v1.application.service + +import com.devooks.backend.notification.v1.adapter.`in`.dto.GetNotificationsRequest +import com.devooks.backend.notification.v1.adapter.`in`.dto.NotificationResponse +import com.devooks.backend.notification.v1.application.port.`in`.GetNotificationUseCase +import com.devooks.backend.notification.v1.application.port.out.LoadNotificationPort +import java.util.* +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.stereotype.Service + +@Service +class NotificationQueryService( + private val loadNotificationPort: LoadNotificationPort, +) : GetNotificationUseCase { + + override suspend fun getCountOfUnchecked(memberId: UUID): Long = + loadNotificationPort.loadCountOfUnchecked(memberId) + + override suspend fun getNotifications(request: GetNotificationsRequest): Page { + val notifications = loadNotificationPort.loadNotifications(request).map { it.toResponse() } + val count = loadNotificationPort.loadCount(request.memberId) + return PageImpl(notifications.toList(), request.pageable, count) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/domain/Notification.kt b/src/main/kotlin/com/devooks/backend/notification/v1/domain/Notification.kt new file mode 100644 index 0000000..4602117 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/domain/Notification.kt @@ -0,0 +1,48 @@ +package com.devooks.backend.notification.v1.domain + +import com.devooks.backend.notification.v1.adapter.`in`.dto.CreateNotificationRequest +import com.devooks.backend.notification.v1.adapter.`in`.dto.NotificationResponse +import com.devooks.backend.notification.v1.domain.event.NotificationContent +import com.devooks.backend.notification.v1.domain.event.NotificationNote +import com.devooks.backend.notification.v1.error.NotificationError +import java.time.Instant +import java.util.* + +data class Notification( + val id: UUID? = null, + val type: NotificationType, + val content: NotificationContent, + val note: NotificationNote, + val receiverId: UUID, + val notifiedDate: Instant = Instant.now(), + val checked: Boolean = false, +) { + + fun check(requesterId: UUID): Notification { + if (receiverId != requesterId) { + throw NotificationError.FORBIDDEN_MODIFY_NOTIFICATION.exception + } + return copy(checked = true) + } + + fun toResponse() = + NotificationResponse( + id = id!!, + type = type, + content = content, + note = note, + receiverId = receiverId, + notifiedDate = notifiedDate, + checked = checked + ) + + companion object { + fun CreateNotificationRequest.toDomain() = + Notification( + type = type, + content = content, + note = note, + receiverId = receiverId, + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/domain/NotificationType.kt b/src/main/kotlin/com/devooks/backend/notification/v1/domain/NotificationType.kt new file mode 100644 index 0000000..0384940 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/domain/NotificationType.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.notification.v1.domain + +/** + * 알림 유형 + * + * @property REVIEW 리뷰 + * @property REVIEW_COMMENT 리뷰 댓글 + * @property INQUIRY 문의 + * @property INQUIRY_COMMENT 문의 댓글 + * @property ANNOUNCE 공지 + * @property PURCHASE 구매 + * @property SALES 판매 + * @property WITHDRAWAL 출금 + */ +enum class NotificationType { + REVIEW, REVIEW_COMMENT, INQUIRY, INQUIRY_COMMENT, ANNOUNCE, PURCHASE, SALES, WITHDRAWAL +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateEbookInquiryCommentEvent.kt b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateEbookInquiryCommentEvent.kt new file mode 100644 index 0000000..2a9ba07 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateEbookInquiryCommentEvent.kt @@ -0,0 +1,26 @@ +package com.devooks.backend.notification.v1.domain.event + +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.notification.v1.domain.event.serializer.InstantSerializer +import com.devooks.backend.notification.v1.domain.event.serializer.UUIDSerializer +import java.time.Instant +import java.util.* +import kotlinx.serialization.Serializable + +@Serializable +data class CreateEbookInquiryCommentEvent( + @Serializable(with = UUIDSerializer::class) + val ebookInquiryCommentId: UUID, + @Serializable(with = UUIDSerializer::class) + val ebookInquiryId: UUID, + val commenterName: String, + @Serializable(with = UUIDSerializer::class) + val ebookId: UUID, + @Serializable(with = InstantSerializer::class) + val writtenDate: Instant, + @Serializable(with = UUIDSerializer::class) + override val receiverId: UUID, +) : CreateNotificationEvent { + override val notificationType: NotificationType = NotificationType.INQUIRY_COMMENT + override val content: NotificationContent = "[$commenterName] 님이 댓글을 남겼습니다." +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateEbookInquiryEvent.kt b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateEbookInquiryEvent.kt new file mode 100644 index 0000000..0f3af50 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateEbookInquiryEvent.kt @@ -0,0 +1,25 @@ +package com.devooks.backend.notification.v1.domain.event + +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.notification.v1.domain.event.serializer.InstantSerializer +import com.devooks.backend.notification.v1.domain.event.serializer.UUIDSerializer +import java.time.Instant +import java.util.* +import kotlinx.serialization.Serializable + +@Serializable +data class CreateEbookInquiryEvent( + @Serializable(with = UUIDSerializer::class) + val ebookInquiryId: UUID, + val inquirerName: String, + @Serializable(with = UUIDSerializer::class) + val ebookId: UUID, + val ebookTitle: String, + @Serializable(with = InstantSerializer::class) + val writtenDate: Instant, + @Serializable(with = UUIDSerializer::class) + override val receiverId: UUID, +) : CreateNotificationEvent { + override val notificationType: NotificationType = NotificationType.INQUIRY + override val content: NotificationContent = "[$inquirerName] 님이 [$ebookTitle]에 문의를 남겼습니다." +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateNotificationEvent.kt b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateNotificationEvent.kt new file mode 100644 index 0000000..cd30529 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateNotificationEvent.kt @@ -0,0 +1,19 @@ +package com.devooks.backend.notification.v1.domain.event + +import com.devooks.backend.notification.v1.domain.NotificationType +import java.util.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +typealias NotificationContent = String +typealias NotificationNote = Map + +@Serializable +sealed interface CreateNotificationEvent { + val receiverId: UUID + val notificationType: NotificationType + val content: NotificationContent + + fun createNote(): NotificationNote = + Json.decodeFromString(Json.encodeToString(kotlinx.serialization.serializer(), this)) +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateReviewCommentEvent.kt b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateReviewCommentEvent.kt new file mode 100644 index 0000000..a3743a2 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateReviewCommentEvent.kt @@ -0,0 +1,26 @@ +package com.devooks.backend.notification.v1.domain.event + +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.notification.v1.domain.event.serializer.InstantSerializer +import com.devooks.backend.notification.v1.domain.event.serializer.UUIDSerializer +import java.time.Instant +import java.util.* +import kotlinx.serialization.Serializable + +@Serializable +data class CreateReviewCommentEvent( + @Serializable(with = UUIDSerializer::class) + val reviewCommentId: UUID, + @Serializable(with = UUIDSerializer::class) + val reviewId: UUID, + val commenterName: String, + @Serializable(with = UUIDSerializer::class) + val ebookId: UUID, + @Serializable(with = InstantSerializer::class) + val writtenDate: Instant, + @Serializable(with = UUIDSerializer::class) + override val receiverId: UUID, +) : CreateNotificationEvent { + override val notificationType: NotificationType = NotificationType.REVIEW_COMMENT + override val content: NotificationContent = "[$commenterName] 님이 댓글을 남겼습니다." +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateReviewEvent.kt b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateReviewEvent.kt new file mode 100644 index 0000000..ae44e81 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/CreateReviewEvent.kt @@ -0,0 +1,25 @@ +package com.devooks.backend.notification.v1.domain.event + +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.notification.v1.domain.event.serializer.InstantSerializer +import com.devooks.backend.notification.v1.domain.event.serializer.UUIDSerializer +import java.time.Instant +import java.util.* +import kotlinx.serialization.Serializable + +@Serializable +data class CreateReviewEvent( + @Serializable(with = UUIDSerializer::class) + val reviewId: UUID, + val reviewerName: String, + @Serializable(with = UUIDSerializer::class) + val ebookId: UUID, + val ebookTitle: String, + @Serializable(with = InstantSerializer::class) + val writtenDate: Instant, + @Serializable(with = UUIDSerializer::class) + override val receiverId: UUID, +) : CreateNotificationEvent { + override val notificationType: NotificationType = NotificationType.REVIEW + override val content: NotificationContent = "[$reviewerName] 님이 [$ebookTitle]에 리뷰를 남겼습니다." +} diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/serializer/InstantSerializer.kt b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/serializer/InstantSerializer.kt new file mode 100644 index 0000000..f9e280b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/serializer/InstantSerializer.kt @@ -0,0 +1,21 @@ +package com.devooks.backend.notification.v1.domain.event.serializer + +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object InstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Instant) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Instant { + return Instant.parse(decoder.decodeString()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/serializer/UUIDSerializer.kt b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/serializer/UUIDSerializer.kt new file mode 100644 index 0000000..f810abc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/domain/event/serializer/UUIDSerializer.kt @@ -0,0 +1,21 @@ +package com.devooks.backend.notification.v1.domain.event.serializer + +import java.util.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object UUIDSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: UUID) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): UUID { + return UUID.fromString(decoder.decodeString()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/error/NotificationError.kt b/src/main/kotlin/com/devooks/backend/notification/v1/error/NotificationError.kt new file mode 100644 index 0000000..10f4595 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/error/NotificationError.kt @@ -0,0 +1,15 @@ +package com.devooks.backend.notification.v1.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus + +enum class NotificationError(val exception: GeneralException) { + // 400 + INVALID_NOTIFICATION_ID(GeneralException("NOTIFICATION-400-1", HttpStatus.BAD_REQUEST, "잘못된 형식의 알림 식별자 입니다.")), + + // 403 + FORBIDDEN_MODIFY_NOTIFICATION(GeneralException("NOTIFICATION-403-1", HttpStatus.FORBIDDEN, "자신의 알림만 변경할 수 있습니다.")), + + // 404 + NOT_FOUND_NOTIFICATION(GeneralException("NOTIFICATION-404-1", HttpStatus.NOT_FOUND, "알림이 존재하지 않습니다.")) +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/notification/v1/error/NotificationValidation.kt b/src/main/kotlin/com/devooks/backend/notification/v1/error/NotificationValidation.kt new file mode 100644 index 0000000..abbbd6f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/notification/v1/error/NotificationValidation.kt @@ -0,0 +1,7 @@ +package com.devooks.backend.notification.v1.error + +import com.devooks.backend.common.error.validateUUID +import java.util.* + +fun String?.validateNotificationId(): UUID? = + this?.validateUUID(NotificationError.INVALID_NOTIFICATION_ID.exception) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/controller/PdfController.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/controller/PdfController.kt new file mode 100644 index 0000000..9b571d0 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/controller/PdfController.kt @@ -0,0 +1,57 @@ +package com.devooks.backend.pdf.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.pdf.v1.domain.Pdf +import com.devooks.backend.pdf.v1.domain.PreviewImage +import com.devooks.backend.pdf.v1.dto.GetPreviewImageListResponse +import com.devooks.backend.pdf.v1.dto.PdfDto.Companion.toDto +import com.devooks.backend.pdf.v1.dto.PreviewImageDto.Companion.toDto +import com.devooks.backend.pdf.v1.dto.UploadPdfResponse +import com.devooks.backend.pdf.v1.service.PdfService +import com.devooks.backend.pdf.v1.service.PreviewImageService +import java.util.* +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType +import org.springframework.http.codec.multipart.FilePart +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/pdfs") +class PdfController( + private val tokenService: TokenService, + private val pdfService: PdfService, + private val previewImageService: PreviewImageService, +) { + + @Transactional + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + suspend fun uploadPdf( + @RequestPart("pdf") + filePart: FilePart, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): UploadPdfResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val pdf: Pdf = pdfService.save(filePart, requesterId) + val previewImageList: List = previewImageService.save(pdf) + return UploadPdfResponse(pdf.toDto(previewImageList)) + } + + @GetMapping("/{pdfId}/preview") + suspend fun getPreviewImageList( + @PathVariable + pdfId: UUID, + ): GetPreviewImageListResponse { + val pdf: Pdf = pdfService.findBy(pdfId) + val previewImageList: List = previewImageService.findBy(pdf) + return GetPreviewImageListResponse(previewImageList.map { it.toDto() }.sortedBy { it.previewOrder }) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/domain/Pdf.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/domain/Pdf.kt new file mode 100644 index 0000000..3dc61bc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/domain/Pdf.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.pdf.v1.domain + +import java.time.Instant +import java.util.* + +class Pdf( + val id: UUID, + val uploadMemberId: UUID, + val createdDate: Instant, + val info: PdfInfo +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/domain/PdfInfo.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/domain/PdfInfo.kt new file mode 100644 index 0000000..3862d1d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/domain/PdfInfo.kt @@ -0,0 +1,8 @@ +package com.devooks.backend.pdf.v1.domain + +import java.nio.file.Path + +class PdfInfo( + val filePath: Path, + val pageCount: Int, +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/domain/PreviewImage.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/domain/PreviewImage.kt new file mode 100644 index 0000000..8f8033a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/domain/PreviewImage.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.pdf.v1.domain + +import java.util.* + +class PreviewImage( + val id: UUID, + val pdfId: UUID, + val info: PreviewImageInfo +) diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/domain/PreviewImageInfo.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/domain/PreviewImageInfo.kt new file mode 100644 index 0000000..d00a20c --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/domain/PreviewImageInfo.kt @@ -0,0 +1,8 @@ +package com.devooks.backend.pdf.v1.domain + +import java.nio.file.Path + +class PreviewImageInfo( + val order: Int, + val imagePath: Path, +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/dto/GetPreviewImageListResponse.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/GetPreviewImageListResponse.kt new file mode 100644 index 0000000..64b30b0 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/GetPreviewImageListResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.pdf.v1.dto + +data class GetPreviewImageListResponse( + val previewImageList: List +) diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/dto/PdfDto.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/PdfDto.kt new file mode 100644 index 0000000..a7bb046 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/PdfDto.kt @@ -0,0 +1,27 @@ +package com.devooks.backend.pdf.v1.dto + +import com.devooks.backend.pdf.v1.domain.Pdf +import com.devooks.backend.pdf.v1.domain.PreviewImage +import com.devooks.backend.pdf.v1.dto.PdfInfoDto.Companion.toDto +import com.devooks.backend.pdf.v1.dto.PreviewImageDto.Companion.toDto +import java.time.Instant +import java.util.* + +data class PdfDto( + val id: UUID, + val uploadMemberId: UUID, + val createdDate: Instant, + val pdfInfo: PdfInfoDto, + val previewImageList: List, +) { + companion object { + fun Pdf.toDto(previewImageList: List) = + PdfDto( + id = this.id, + uploadMemberId = this.uploadMemberId, + createdDate = this.createdDate, + pdfInfo = this.info.toDto(), + previewImageList = previewImageList.map { it.toDto() } + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/dto/PdfInfoDto.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/PdfInfoDto.kt new file mode 100644 index 0000000..16e2605 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/PdfInfoDto.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.pdf.v1.dto + +import com.devooks.backend.pdf.v1.domain.PdfInfo +import kotlin.io.path.pathString + +data class PdfInfoDto( + val pageCount: Int, + val filePath: String, +) { + companion object { + fun PdfInfo.toDto() = + PdfInfoDto( + pageCount = this.pageCount, + filePath = this.filePath.pathString, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/dto/PreviewImageDto.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/PreviewImageDto.kt new file mode 100644 index 0000000..033b22e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/PreviewImageDto.kt @@ -0,0 +1,22 @@ +package com.devooks.backend.pdf.v1.dto + +import com.devooks.backend.pdf.v1.domain.PreviewImage +import java.util.* +import kotlin.io.path.pathString + +data class PreviewImageDto( + val id: UUID, + val imagePath: String, + val previewOrder: Int, + val pdfId: UUID, +) { + companion object { + fun PreviewImage.toDto() = + PreviewImageDto( + id = this.id, + imagePath = this.info.imagePath.pathString, + previewOrder = this.info.order, + pdfId = this.pdfId + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/dto/UploadPdfResponse.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/UploadPdfResponse.kt new file mode 100644 index 0000000..52a60f2 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/dto/UploadPdfResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.pdf.v1.dto + +data class UploadPdfResponse( + val pdf: PdfDto, +) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/entity/PdfEntity.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/entity/PdfEntity.kt new file mode 100644 index 0000000..6a7a3d9 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/entity/PdfEntity.kt @@ -0,0 +1,48 @@ +package com.devooks.backend.pdf.v1.entity + +import com.devooks.backend.pdf.v1.domain.Pdf +import com.devooks.backend.pdf.v1.domain.PdfInfo +import java.time.Instant +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.pathString +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "pdf") +data class PdfEntity( + @Id + @Column("pdf_id") + @get:JvmName("pdfId") + val id: UUID? = null, + val filePath: String, + val pageCount: Int, + val uploadMemberId: UUID, + val createdDate: Instant = Instant.now(), +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain(): Pdf = + Pdf( + id = this.id!!, + uploadMemberId = this.uploadMemberId, + createdDate = this.createdDate, + info = PdfInfo( + filePath = Path(this.filePath), + pageCount = this.pageCount + ) + ) + + companion object { + fun PdfInfo.toEntity(uploadMemberId: UUID): PdfEntity = + PdfEntity( + filePath = this.filePath.pathString, + pageCount = this.pageCount, + uploadMemberId = uploadMemberId + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/entity/PreviewImageEntity.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/entity/PreviewImageEntity.kt new file mode 100644 index 0000000..26d14fe --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/entity/PreviewImageEntity.kt @@ -0,0 +1,43 @@ +package com.devooks.backend.pdf.v1.entity + +import com.devooks.backend.pdf.v1.domain.PreviewImage +import com.devooks.backend.pdf.v1.domain.PreviewImageInfo +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.pathString +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "preview_image") +data class PreviewImageEntity( + @Id + @Column("preview_image_id") + @get:JvmName("previewImageId") + val id: UUID? = null, + val imagePath: String, + val previewOrder: Int, + val pdfId: UUID, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain(): PreviewImage = PreviewImage( + id = this.id!!, + pdfId = this.pdfId, + info = PreviewImageInfo( + order = previewOrder, + imagePath = Path(imagePath) + ) + ) + + companion object { + fun PreviewImageInfo.toEntity(pdfId: UUID) = PreviewImageEntity( + imagePath = this.imagePath.pathString, + previewOrder = this.order, + pdfId = pdfId + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/error/PdfError.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/error/PdfError.kt new file mode 100644 index 0000000..9cedb55 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/error/PdfError.kt @@ -0,0 +1,26 @@ +package com.devooks.backend.pdf.v1.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR +import org.springframework.http.HttpStatus.NOT_FOUND + +enum class PdfError(val exception: GeneralException) { + // 400 + INVALID_PDF_FILE_SIZE(GeneralException("PDF-400-1", BAD_REQUEST, "PDF 파일은 0GB 이상 1GB 이하만 업로드 가능합니다.")), + INVALID_PDF_FILE_PAGE_COUNT(GeneralException("PDF-400-2", BAD_REQUEST, "PDF 파일은 최소 5장 이상만 업로드 가능합니다.")), + UNREADABLE_PDF_FILE(GeneralException("PDF-400-3", BAD_REQUEST, "읽을 수 없는 PDF 파일입니다.")), + + // 403 + FORBIDDEN_CREATE_EBOOK(GeneralException("PDF-403-1", FORBIDDEN, "읽을 수 없는 PDF 파일입니다.")), + + // 404 + NOT_FOUND_PDF(GeneralException("PDF-404-1", NOT_FOUND, "존재하지 않는 PDF 입니다.")), + + + // 500 + FAIL_SAVE_PDF_FILE(GeneralException("PDF-500-1", INTERNAL_SERVER_ERROR, "PDF 파일 저장을 실패했습니다.")), + FAIL_SAVE_PREVIEW_IMAGE_FILES(GeneralException("PDF-500-2", INTERNAL_SERVER_ERROR, "미리보기 파일 저장을 실패했습니다.")), + FAIL_FIND_PREVIEW_IMAGE(GeneralException("PDF-500-3", INTERNAL_SERVER_ERROR, "미리보기 파일 조회를 실패했습니다.")), +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/repository/PdfRepository.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/repository/PdfRepository.kt new file mode 100644 index 0000000..d059221 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/repository/PdfRepository.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.pdf.v1.repository + +import com.devooks.backend.pdf.v1.entity.PdfEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface PdfRepository : CoroutineCrudRepository diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/repository/PreviewImageRepository.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/repository/PreviewImageRepository.kt new file mode 100644 index 0000000..5479038 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/repository/PreviewImageRepository.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.pdf.v1.repository + +import com.devooks.backend.pdf.v1.entity.PreviewImageEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface PreviewImageRepository : CoroutineCrudRepository { + + suspend fun findAllByPdfId(pdfId: UUID): List +} diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/service/PdfResolver.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/service/PdfResolver.kt new file mode 100644 index 0000000..b754999 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/service/PdfResolver.kt @@ -0,0 +1,118 @@ +package com.devooks.backend.pdf.v1.service + +import com.devooks.backend.BackendApplication.Companion.PDF_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.PREVIEW_IMAGE_ROOT_PATH +import com.devooks.backend.common.exception.GeneralException +import com.devooks.backend.common.utils.logger +import com.devooks.backend.pdf.v1.domain.PdfInfo +import com.devooks.backend.pdf.v1.domain.PreviewImageInfo +import com.devooks.backend.pdf.v1.error.PdfError +import java.nio.file.Path +import java.nio.file.Paths +import java.util.* +import javax.imageio.ImageIO +import kotlin.io.path.fileSize +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.withContext +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.rendering.ImageType +import org.apache.pdfbox.rendering.PDFRenderer +import org.springframework.http.codec.multipart.FilePart +import org.springframework.stereotype.Component + +@Component +class PdfResolver { + + private val logger = logger() + private val pdfUploadDirectory = Paths.get(PDF_ROOT_PATH) + private val previewImageDirectory = Paths.get(PREVIEW_IMAGE_ROOT_PATH) + + suspend fun savePdf(filePart: FilePart): PdfInfo = + runCatching { + val pdfFilePath = savePdfFile(filePart) + validatePdfFile(pdfFilePath) + val numberOfPages = getNumberOfPages(pdfFilePath) + PdfInfo(filePath = pdfFilePath, pageCount = numberOfPages) + }.getOrElse { exception -> + throw when (exception) { + is GeneralException -> exception + else -> { + val generalException = PdfError.FAIL_SAVE_PDF_FILE.exception + logger.error(generalException.message) + logger.error(exception.stackTraceToString()) + generalException + } + } + } + + suspend fun savePreviewImages(pdf: PdfInfo): List = + runCatching { + val pdfFile = pdf.filePath.toFile() + + PDDocument + .load(pdfFile) + .use { document -> savePreviewImageFiles(document).toList() } + }.getOrElse { exception -> + val generalException = PdfError.FAIL_SAVE_PREVIEW_IMAGE_FILES.exception + logger.error(generalException.message) + logger.error(exception.stackTraceToString()) + throw generalException + } + + private fun savePreviewImageFiles(document: PDDocument?): Flow { + val pdfRenderer = PDFRenderer(document) + return List(3) { index -> index } + .asFlow() + .map { index -> + val previewImageFile = previewImageDirectory.resolve("${UUID.randomUUID()}.jpg").toFile() + val image = pdfRenderer.renderImageWithDPI(index, PREVIEW_IMAGE_DPI, ImageType.RGB) + ImageIO.write(image, PREVIEW_IMAGE_EXTENSION, previewImageFile) + PreviewImageInfo(order = index + 1, imagePath = previewImageFile.toPath()) + } + .flowOn(Dispatchers.IO) + } + + private fun validatePdfFile(pdfFilePath: Path) { + val fileSize = pdfFilePath.fileSize() + if (fileSize <= MIN_PDF_FILE_BYTE_SIZE || fileSize > MAX_PDF_FILE_BYTE_SIZE) { + throw PdfError.INVALID_PDF_FILE_SIZE.exception + } + } + + private fun getNumberOfPages(pdfFilePath: Path): Int = + runCatching { + PDDocument.load(pdfFilePath.toFile()).use { document -> + val numberOfPages = document.numberOfPages + if (numberOfPages < MIN_PDF_PAGE_COUNT) { + throw PdfError.INVALID_PDF_FILE_PAGE_COUNT.exception + } + numberOfPages + } + }.getOrElse { exception -> + throw when (exception) { + is GeneralException -> exception + else -> PdfError.UNREADABLE_PDF_FILE.exception + } + } + + private suspend fun savePdfFile(filePart: FilePart): Path { + val pdfFilePath = pdfUploadDirectory.resolve("${UUID.randomUUID()}.pdf") + withContext(Dispatchers.IO) { + filePart.transferTo(pdfFilePath).block() + } + return pdfFilePath + } + + companion object { + const val MIN_PDF_FILE_BYTE_SIZE = 0 + const val MAX_PDF_FILE_BYTE_SIZE = 1_000_000_000 + const val MIN_PDF_PAGE_COUNT = 5 + const val PREVIEW_IMAGE_DPI = 300f + const val PREVIEW_IMAGE_EXTENSION = "jpg" + } +} diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/service/PdfService.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/service/PdfService.kt new file mode 100644 index 0000000..7fca643 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/service/PdfService.kt @@ -0,0 +1,39 @@ +package com.devooks.backend.pdf.v1.service + +import com.devooks.backend.ebook.v1.dto.command.CreateEbookCommand +import com.devooks.backend.pdf.v1.domain.Pdf +import com.devooks.backend.pdf.v1.domain.PdfInfo +import com.devooks.backend.pdf.v1.entity.PdfEntity.Companion.toEntity +import com.devooks.backend.pdf.v1.error.PdfError +import com.devooks.backend.pdf.v1.repository.PdfRepository +import java.util.* +import org.springframework.http.codec.multipart.FilePart +import org.springframework.stereotype.Service + +@Service +class PdfService( + private val pdfResolver: PdfResolver, + private val pdfRepository: PdfRepository, +) { + + suspend fun save(filePart: FilePart, requesterId: UUID): Pdf { + val pdfInfo: PdfInfo = pdfResolver.savePdf(filePart) + val pdfEntity = pdfInfo.toEntity(requesterId) + val savedPdf = pdfRepository.save(pdfEntity) + return savedPdf.toDomain() + } + + suspend fun findBy(pdfId: UUID): Pdf = + pdfRepository + .findById(pdfId) + ?.toDomain() + ?: throw PdfError.NOT_FOUND_PDF.exception + + suspend fun validate(command: CreateEbookCommand) { + val pdf = findBy(command.pdfId) + if (pdf.uploadMemberId != command.sellingMemberId) { + throw PdfError.FORBIDDEN_CREATE_EBOOK.exception + } + } + +} diff --git a/src/main/kotlin/com/devooks/backend/pdf/v1/service/PreviewImageService.kt b/src/main/kotlin/com/devooks/backend/pdf/v1/service/PreviewImageService.kt new file mode 100644 index 0000000..6f64be4 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/pdf/v1/service/PreviewImageService.kt @@ -0,0 +1,32 @@ +package com.devooks.backend.pdf.v1.service + +import com.devooks.backend.pdf.v1.domain.Pdf +import com.devooks.backend.pdf.v1.domain.PreviewImage +import com.devooks.backend.pdf.v1.domain.PreviewImageInfo +import com.devooks.backend.pdf.v1.entity.PreviewImageEntity.Companion.toEntity +import com.devooks.backend.pdf.v1.error.PdfError +import com.devooks.backend.pdf.v1.repository.PreviewImageRepository +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +@Service +class PreviewImageService( + private val pdfResolver: PdfResolver, + private val previewImageRepository: PreviewImageRepository, +) { + + suspend fun save(pdf: Pdf): List { + val infoList: List = pdfResolver.savePreviewImages(pdf.info) + val entityList = infoList.map { it.toEntity(pdf.id) } + val savedEntityList = previewImageRepository.saveAll(entityList) + return savedEntityList.map { it.toDomain() }.toList() + } + + suspend fun findBy(pdf: Pdf): List = + previewImageRepository + .findAllByPdfId(pdf.id) + .takeIf { it.isNotEmpty() } + ?.map { it.toDomain() } + ?: throw PdfError.FAIL_FIND_PREVIEW_IMAGE.exception +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/controller/ReviewCommentController.kt b/src/main/kotlin/com/devooks/backend/review/v1/controller/ReviewCommentController.kt new file mode 100644 index 0000000..500c6f5 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/controller/ReviewCommentController.kt @@ -0,0 +1,104 @@ +package com.devooks.backend.review.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.review.v1.domain.ReviewComment +import com.devooks.backend.review.v1.dto.CreateReviewCommentCommand +import com.devooks.backend.review.v1.dto.CreateReviewCommentRequest +import com.devooks.backend.review.v1.dto.CreateReviewCommentResponse +import com.devooks.backend.review.v1.dto.CreateReviewCommentResponse.Companion.toCreateReviewCommentResponse +import com.devooks.backend.review.v1.dto.DeleteReviewCommentCommand +import com.devooks.backend.review.v1.dto.DeleteReviewCommentResponse +import com.devooks.backend.review.v1.dto.GetReviewCommentsCommand +import com.devooks.backend.review.v1.dto.GetReviewCommentsResponse +import com.devooks.backend.review.v1.dto.GetReviewCommentsResponse.Companion.toGetReviewCommentsResponse +import com.devooks.backend.review.v1.dto.ModifyReviewCommentCommand +import com.devooks.backend.review.v1.dto.ModifyReviewCommentRequest +import com.devooks.backend.review.v1.dto.ModifyReviewCommentResponse +import com.devooks.backend.review.v1.dto.ModifyReviewCommentResponse.Companion.toModifyReviewCommentResponse +import com.devooks.backend.review.v1.service.ReviewCommentEventService +import com.devooks.backend.review.v1.service.ReviewCommentService +import com.devooks.backend.review.v1.service.ReviewService +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/review-comments") +class ReviewCommentController( + private val reviewCommentService: ReviewCommentService, + private val reviewCommentEventService: ReviewCommentEventService, + private val tokenService: TokenService, + private val reviewService: ReviewService, +) { + + @Transactional + @PostMapping + suspend fun createReviewComment( + @RequestBody + request: CreateReviewCommentRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): CreateReviewCommentResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: CreateReviewCommentCommand = request.toCommand(requesterId) + reviewService.validate(command) + val reviewComment: ReviewComment = reviewCommentService.create(command) + reviewCommentEventService.publish(reviewComment) + return reviewComment.toCreateReviewCommentResponse() + } + + @GetMapping + suspend fun getReviewComments( + @RequestParam(required = false, defaultValue = "") + reviewId: String, + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + ): GetReviewCommentsResponse { + val command = GetReviewCommentsCommand(reviewId, page, count) + val reviewCommentList: List = reviewCommentService.get(command) + return reviewCommentList.toGetReviewCommentsResponse() + } + + @Transactional + @PatchMapping("/{commentId}") + suspend fun modifyReviewComment( + @PathVariable(name = "commentId", required = false) + commentId: String, + @RequestBody + request: ModifyReviewCommentRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): ModifyReviewCommentResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyReviewCommentCommand = request.toCommand(commentId, requesterId) + val reviewComment: ReviewComment = reviewCommentService.modify(command) + return reviewComment.toModifyReviewCommentResponse() + } + + @Transactional + @DeleteMapping("/{commentId}") + suspend fun deleteReviewComment( + @PathVariable(name = "commentId", required = false) + commentId: String, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): DeleteReviewCommentResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command = DeleteReviewCommentCommand(commentId, requesterId) + reviewCommentService.delete(command) + return DeleteReviewCommentResponse() + } + +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/controller/ReviewController.kt b/src/main/kotlin/com/devooks/backend/review/v1/controller/ReviewController.kt new file mode 100644 index 0000000..9f3d69f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/controller/ReviewController.kt @@ -0,0 +1,110 @@ +package com.devooks.backend.review.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.ebook.v1.service.EbookService +import com.devooks.backend.review.v1.domain.Review +import com.devooks.backend.review.v1.dto.CreateReviewCommand +import com.devooks.backend.review.v1.dto.CreateReviewRequest +import com.devooks.backend.review.v1.dto.CreateReviewResponse +import com.devooks.backend.review.v1.dto.CreateReviewResponse.Companion.toCreateReviewResponse +import com.devooks.backend.review.v1.dto.DeleteReviewCommand +import com.devooks.backend.review.v1.dto.DeleteReviewResponse +import com.devooks.backend.review.v1.dto.GetReviewsCommand +import com.devooks.backend.review.v1.dto.GetReviewsResponse +import com.devooks.backend.review.v1.dto.GetReviewsResponse.Companion.toGetReviewsResponse +import com.devooks.backend.review.v1.dto.ModifyReviewCommand +import com.devooks.backend.review.v1.dto.ModifyReviewRequest +import com.devooks.backend.review.v1.dto.ModifyReviewResponse +import com.devooks.backend.review.v1.dto.ModifyReviewResponse.Companion.toModifyReviewResponse +import com.devooks.backend.review.v1.service.ReviewEventService +import com.devooks.backend.review.v1.service.ReviewService +import com.devooks.backend.transaciton.v1.service.TransactionService +import java.util.* +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/reviews") +class ReviewController( + private val reviewService: ReviewService, + private val tokenService: TokenService, + private val transactionService: TransactionService, + private val ebookService: EbookService, + private val reviewEventService: ReviewEventService, +) { + + @Transactional + @PostMapping + suspend fun createReview( + @RequestBody + request: CreateReviewRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): CreateReviewResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command: CreateReviewCommand = request.toCommand(requesterId) + ebookService.validate(command) + transactionService.validate(command) + val review: Review = reviewService.create(command) + reviewEventService.publish(review) + return review.toCreateReviewResponse() + } + + @GetMapping + suspend fun getReviews( + @RequestParam(required = false, defaultValue = "") + ebookId: String, + @RequestParam(required = false, defaultValue = "") + memberId: String, + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + ): GetReviewsResponse { + val command = GetReviewsCommand(ebookId, memberId, page, count) + val reviewList: List = reviewService.get(command) + return reviewList.toGetReviewsResponse() + } + + @Transactional + @PatchMapping("/{reviewId}") + suspend fun modifyReview( + @PathVariable(name = "reviewId", required = false) + reviewId: String, + @RequestBody + request: ModifyReviewRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): ModifyReviewResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyReviewCommand = request.toCommand(reviewId, requesterId) + val review: Review = reviewService.modify(command) + return review.toModifyReviewResponse() + } + + @Transactional + @DeleteMapping("/{reviewId}") + suspend fun deleteReview( + @PathVariable(name = "reviewId", required = false) + reviewId: String, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): DeleteReviewResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command = DeleteReviewCommand(reviewId, requesterId) + reviewService.delete(command) + return DeleteReviewResponse() + } + +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/domain/Review.kt b/src/main/kotlin/com/devooks/backend/review/v1/domain/Review.kt new file mode 100644 index 0000000..17ffe8e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/domain/Review.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.review.v1.domain + +import java.time.Instant +import java.util.* + +class Review( + val id: UUID, + val rating: Int, + val content: String, + val ebookId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant, + val modifiedDate: Instant, +) diff --git a/src/main/kotlin/com/devooks/backend/review/v1/domain/ReviewComment.kt b/src/main/kotlin/com/devooks/backend/review/v1/domain/ReviewComment.kt new file mode 100644 index 0000000..03d2f6b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/domain/ReviewComment.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.review.v1.domain + +import java.time.Instant +import java.util.* + +class ReviewComment( + val id: UUID, + val content: String, + val reviewId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant, + val modifiedDate: Instant, +) diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommand.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommand.kt new file mode 100644 index 0000000..0df4a49 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommand.kt @@ -0,0 +1,10 @@ +package com.devooks.backend.review.v1.dto + +import java.util.* + +class CreateReviewCommand( + val ebookId: UUID, + val rating: Int, + val content: String, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommentCommand.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommentCommand.kt new file mode 100644 index 0000000..d0611cd --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommentCommand.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.review.v1.dto + +import java.util.* + +class CreateReviewCommentCommand( + val reviewId: UUID, + val content: String, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommentRequest.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommentRequest.kt new file mode 100644 index 0000000..c7c2817 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommentRequest.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.error.validateReviewContent +import com.devooks.backend.review.v1.error.validateReviewId +import java.util.* + +data class CreateReviewCommentRequest( + val reviewId: String?, + val content: String?, +) { + fun toCommand(requesterId: UUID): CreateReviewCommentCommand = + CreateReviewCommentCommand( + reviewId = reviewId.validateReviewId(), + content = content.validateReviewContent(), + requesterId = requesterId, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommentResponse.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommentResponse.kt new file mode 100644 index 0000000..4f18e2d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewCommentResponse.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.domain.ReviewComment +import com.devooks.backend.review.v1.dto.ReviewCommentDto.Companion.toDto + +data class CreateReviewCommentResponse( + val reviewComment: ReviewCommentDto, +) { + companion object { + fun ReviewComment.toCreateReviewCommentResponse() = + CreateReviewCommentResponse(this.toDto()) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewRequest.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewRequest.kt new file mode 100644 index 0000000..6f927a4 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewRequest.kt @@ -0,0 +1,20 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.error.validateRating +import com.devooks.backend.review.v1.error.validateReviewContent +import com.devooks.backend.wishlist.v1.error.validateEbookId +import java.util.* + +data class CreateReviewRequest( + val ebookId: String?, + val rating: String?, + val content: String? +) { + fun toCommand(requesterId: UUID): CreateReviewCommand = + CreateReviewCommand( + ebookId = ebookId.validateEbookId(), + rating = rating.validateRating(), + content = content.validateReviewContent(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewResponse.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewResponse.kt new file mode 100644 index 0000000..506ff04 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/CreateReviewResponse.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.domain.Review +import com.devooks.backend.review.v1.dto.ReviewDto.Companion.toDto + +data class CreateReviewResponse( + val review: ReviewDto, +) { + companion object { + fun Review.toCreateReviewResponse() = CreateReviewResponse(this.toDto()) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewCommand.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewCommand.kt new file mode 100644 index 0000000..553906f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewCommand.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.error.validateReviewId +import java.util.* + +data class DeleteReviewCommand( + val reviewId: UUID, + val requesterId: UUID, +) { + constructor( + reviewId: String, + requesterId: UUID, + ) : this( + reviewId = reviewId.validateReviewId(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewCommentCommand.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewCommentCommand.kt new file mode 100644 index 0000000..76bf816 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewCommentCommand.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.error.validateReviewCommentId +import java.util.* + +class DeleteReviewCommentCommand( + val commentId: UUID, + val requesterId: UUID, +) { + constructor( + commentId: String, + requesterId: UUID, + ) : this( + commentId = commentId.validateReviewCommentId(), + requesterId = requesterId, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewCommentResponse.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewCommentResponse.kt new file mode 100644 index 0000000..3b6889f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewCommentResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.review.v1.dto + +data class DeleteReviewCommentResponse( + val message: String = "리뷰 댓글 삭제를 완료했습니다." +) diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewResponse.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewResponse.kt new file mode 100644 index 0000000..d7060b3 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/DeleteReviewResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.review.v1.dto + +data class DeleteReviewResponse( + val message: String = "리뷰 삭제를 완료했습니다." +) diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewCommentsCommand.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewCommentsCommand.kt new file mode 100644 index 0000000..5f10074 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewCommentsCommand.kt @@ -0,0 +1,23 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.common.dto.Paging +import com.devooks.backend.review.v1.error.validateReviewId +import java.util.* +import org.springframework.data.domain.Pageable + +data class GetReviewCommentsCommand( + val reviewId: UUID, + private val paging: Paging +) { + constructor( + reviewId: String, + page: String, + count: String + ) : this( + reviewId = reviewId.validateReviewId(), + paging = Paging(page, count) + ) + + val pageable: Pageable + get() = paging.value +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewCommentsResponse.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewCommentsResponse.kt new file mode 100644 index 0000000..8d91d2f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewCommentsResponse.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.domain.ReviewComment +import com.devooks.backend.review.v1.dto.ReviewCommentDto.Companion.toDto + +data class GetReviewCommentsResponse( + val reviewComments: List, +) { + companion object { + fun List.toGetReviewCommentsResponse() = + GetReviewCommentsResponse(map { it.toDto() }) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewsCommand.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewsCommand.kt new file mode 100644 index 0000000..c45fd74 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewsCommand.kt @@ -0,0 +1,29 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.common.dto.Paging +import com.devooks.backend.member.v1.error.validateMemberId +import com.devooks.backend.wishlist.v1.error.validateEbookId +import java.util.* + +class GetReviewsCommand( + val ebookId: UUID?, + val memberId: UUID?, + private val paging: Paging, +) { + constructor( + ebookId: String, + memberId: String, + page: String, + count: String, + ) : this( + ebookId = ebookId.takeIf { it.isNotBlank() }?.validateEbookId(), + memberId = memberId.takeIf { it.isNotBlank() }?.validateMemberId(), + paging = Paging(page, count) + ) + + val offset: Int + get() = paging.offset + + val limit: Int + get() = paging.limit +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewsResponse.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewsResponse.kt new file mode 100644 index 0000000..03faa48 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/GetReviewsResponse.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.domain.Review +import com.devooks.backend.review.v1.dto.ReviewDto.Companion.toDto + +data class GetReviewsResponse( + val reviews: List, +) { + companion object { + fun List.toGetReviewsResponse() = + GetReviewsResponse(reviews = this.map { it.toDto() }) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommand.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommand.kt new file mode 100644 index 0000000..2d6d624 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommand.kt @@ -0,0 +1,10 @@ +package com.devooks.backend.review.v1.dto + +import java.util.* + +class ModifyReviewCommand( + val reviewId: UUID, + val rating: Int, + val content: String, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommentCommand.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommentCommand.kt new file mode 100644 index 0000000..689865a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommentCommand.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.review.v1.dto + +import java.util.* + +class ModifyReviewCommentCommand( + val content: String, + val commentId: UUID, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommentRequest.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommentRequest.kt new file mode 100644 index 0000000..865b099 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommentRequest.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.error.validateReviewCommentId +import com.devooks.backend.review.v1.error.validateReviewContent +import java.util.* + +data class ModifyReviewCommentRequest( + val content: String?, +) { + fun toCommand(commentId: String, requesterId: UUID): ModifyReviewCommentCommand = + ModifyReviewCommentCommand( + content = content.validateReviewContent(), + commentId = commentId.validateReviewCommentId(), + requesterId = requesterId, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommentResponse.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommentResponse.kt new file mode 100644 index 0000000..a32d443 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewCommentResponse.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.domain.ReviewComment +import com.devooks.backend.review.v1.dto.ReviewCommentDto.Companion.toDto + +data class ModifyReviewCommentResponse( + val reviewComment: ReviewCommentDto +) { + companion object { + fun ReviewComment.toModifyReviewCommentResponse() = + ModifyReviewCommentResponse(toDto()) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewRequest.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewRequest.kt new file mode 100644 index 0000000..e7f8218 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewRequest.kt @@ -0,0 +1,19 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.error.validateRating +import com.devooks.backend.review.v1.error.validateReviewContent +import com.devooks.backend.review.v1.error.validateReviewId +import java.util.* + +data class ModifyReviewRequest( + val rating: String?, + val content: String?, +) { + fun toCommand(reviewId: String, requesterId: UUID): ModifyReviewCommand = + ModifyReviewCommand( + reviewId = reviewId.validateReviewId(), + rating = rating.validateRating(), + content = content.validateReviewContent(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewResponse.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewResponse.kt new file mode 100644 index 0000000..47319f0 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/ModifyReviewResponse.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.domain.Review +import com.devooks.backend.review.v1.dto.ReviewDto.Companion.toDto + +class ModifyReviewResponse( + val review: ReviewDto +) { + companion object { + fun Review.toModifyReviewResponse() = ModifyReviewResponse(this.toDto()) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/ReviewCommentDto.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/ReviewCommentDto.kt new file mode 100644 index 0000000..c62330f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/ReviewCommentDto.kt @@ -0,0 +1,26 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.domain.ReviewComment +import java.time.Instant +import java.util.* + +data class ReviewCommentDto( + val id: UUID, + val content: String, + val reviewId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant, + val modifiedDate: Instant, +) { + companion object { + fun ReviewComment.toDto(): ReviewCommentDto = + ReviewCommentDto( + id = this.id, + content = this.content, + reviewId = this.reviewId, + writerMemberId = this.writerMemberId, + writtenDate = this.writtenDate, + modifiedDate = this.modifiedDate, + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/dto/ReviewDto.kt b/src/main/kotlin/com/devooks/backend/review/v1/dto/ReviewDto.kt new file mode 100644 index 0000000..a3d796b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/dto/ReviewDto.kt @@ -0,0 +1,28 @@ +package com.devooks.backend.review.v1.dto + +import com.devooks.backend.review.v1.domain.Review +import java.time.Instant +import java.util.* + +data class ReviewDto( + val id: UUID, + val rating: Int, + val content: String, + val ebookId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant, + val modifiedDate: Instant, +) { + companion object { + fun Review.toDto(): ReviewDto = + ReviewDto( + id = this.id, + rating = this.rating, + content = this.content, + ebookId = this.ebookId, + writerMemberId = this.writerMemberId, + writtenDate = this.writtenDate, + modifiedDate = this.modifiedDate, + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/entity/ReviewCommentEntity.kt b/src/main/kotlin/com/devooks/backend/review/v1/entity/ReviewCommentEntity.kt new file mode 100644 index 0000000..7377714 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/entity/ReviewCommentEntity.kt @@ -0,0 +1,36 @@ +package com.devooks.backend.review.v1.entity + +import com.devooks.backend.review.v1.domain.ReviewComment +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "review_comment") +data class ReviewCommentEntity( + @Id + @Column(value = "review_comment_id") + @get:JvmName("reviewCommentId") + val id: UUID? = null, + val content: String, + val reviewId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant = Instant.now(), + val modifiedDate: Instant = writtenDate, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + ReviewComment( + id = this.id!!, + content = this.content, + reviewId = this.reviewId, + writerMemberId = this.writerMemberId, + writtenDate = this.writtenDate, + modifiedDate = this.modifiedDate + ) +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/entity/ReviewEntity.kt b/src/main/kotlin/com/devooks/backend/review/v1/entity/ReviewEntity.kt new file mode 100644 index 0000000..e5501fb --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/entity/ReviewEntity.kt @@ -0,0 +1,38 @@ +package com.devooks.backend.review.v1.entity + +import com.devooks.backend.review.v1.domain.Review +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "review") +data class ReviewEntity( + @Id + @Column(value = "review_id") + @get:JvmName("reviewId") + val id: UUID? = null, + val rating: Int, + val content: String, + val ebookId: UUID, + val writerMemberId: UUID, + val writtenDate: Instant = Instant.now(), + val modifiedDate: Instant = writtenDate, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + Review( + id = this.id!!, + rating = this.rating, + content = this.content, + ebookId = this.ebookId, + writerMemberId = this.writerMemberId, + writtenDate = this.writtenDate, + modifiedDate = this.modifiedDate, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/error/ReviewError.kt b/src/main/kotlin/com/devooks/backend/review/v1/error/ReviewError.kt new file mode 100644 index 0000000..8ed23f5 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/error/ReviewError.kt @@ -0,0 +1,31 @@ +package com.devooks.backend.review.v1.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.CONFLICT +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.NOT_FOUND + +enum class ReviewError(val exception: GeneralException) { + // 400 + REQUIRED_RATING(GeneralException("REVIEW-400-1", BAD_REQUEST, "평점이 반드시 필요합니다.")), + INVALID_RATING(GeneralException("REVIEW-400-2", BAD_REQUEST, "잘못된 형식의 평점입니다.")), + REQUIRED_REVIEW_CONTENT(GeneralException("REVIEW-400-3", BAD_REQUEST, "내용이 반드시 필요합니다.")), + REQUIRED_REVIEW_ID(GeneralException("REVIEW-400-4", BAD_REQUEST, "리뷰 식별자가 반드시 필요합니다.")), + INVALID_REVIEW_ID(GeneralException("REVIEW-400-5", BAD_REQUEST, "잘못된 형식의 리뷰 식별자입니다.")), + REQUIRED_REVIEW_COMMENT_ID(GeneralException("REVIEW-400-6", BAD_REQUEST, "리뷰 댓글 식별자가 반드시 필요합니다.")), + INVALID_REVIEW_COMMENT_ID(GeneralException("REVIEW-400-7", BAD_REQUEST, "잘못된 형식의 리뷰 댓글 식별자입니다.")), + + // 403 + FORBIDDEN_MODIFY_REVIEW(GeneralException("REVIEW-403-1", FORBIDDEN, "자신이 작성한 리뷰만 수정할 수 있습니다.")), + FORBIDDEN_MODIFY_REVIEW_COMMENT(GeneralException("REVIEW-403-2", FORBIDDEN, "자신이 작성한 리뷰 댓글만 수정할 수 있습니다.")), + + // 404 + NOT_FOUND_REVIEW(GeneralException("REVIEW-404-1", NOT_FOUND, "존재하지 않는 리뷰입니다.")), + NOT_FOUND_REVIEW_COMMENT(GeneralException("REVIEW-404-2", NOT_FOUND, "존재하지 않는 리뷰 댓글입니다.")), + + // 409 + DUPLICATE_REVIEW(GeneralException("REVIEW-409-1", CONFLICT, "이미 작성한 리뷰입니다.")) + ; + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/review/v1/error/ReviewValidation.kt b/src/main/kotlin/com/devooks/backend/review/v1/error/ReviewValidation.kt new file mode 100644 index 0000000..3d7cdf1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/error/ReviewValidation.kt @@ -0,0 +1,25 @@ +package com.devooks.backend.review.v1.error + +import com.devooks.backend.common.error.validateUUID +import com.devooks.backend.common.error.validateNotBlank +import java.util.* + +fun String?.validateRating(): Int = + validateNotBlank(ReviewError.REQUIRED_RATING.exception) + .runCatching { toInt() }.getOrElse { throw ReviewError.INVALID_RATING.exception } + .also { + if (it !in 0..5) { + throw ReviewError.INVALID_RATING.exception + } + } + +fun String?.validateReviewContent(): String = + validateNotBlank(ReviewError.REQUIRED_REVIEW_CONTENT.exception) + +fun String?.validateReviewId(): UUID = + validateNotBlank(ReviewError.REQUIRED_REVIEW_ID.exception) + .validateUUID(ReviewError.INVALID_REVIEW_ID.exception) + +fun String?.validateReviewCommentId(): UUID = + validateNotBlank(ReviewError.REQUIRED_REVIEW_COMMENT_ID.exception) + .validateUUID(ReviewError.INVALID_REVIEW_COMMENT_ID.exception) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/review/v1/repository/ReviewCommentRepository.kt b/src/main/kotlin/com/devooks/backend/review/v1/repository/ReviewCommentRepository.kt new file mode 100644 index 0000000..0aa2831 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/repository/ReviewCommentRepository.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.review.v1.repository + +import com.devooks.backend.review.v1.entity.ReviewCommentEntity +import java.util.* +import kotlinx.coroutines.flow.Flow +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface ReviewCommentRepository : CoroutineCrudRepository { + + suspend fun findAllByReviewId(reviewId: UUID, pageable: Pageable): Flow +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/repository/ReviewQueryRepository.kt b/src/main/kotlin/com/devooks/backend/review/v1/repository/ReviewQueryRepository.kt new file mode 100644 index 0000000..efd8000 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/repository/ReviewQueryRepository.kt @@ -0,0 +1,60 @@ +package com.devooks.backend.review.v1.repository + +import com.devooks.backend.review.v1.domain.Review +import com.devooks.backend.review.v1.dto.GetReviewsCommand +import io.r2dbc.spi.Readable +import java.math.BigInteger +import java.time.Instant +import java.util.* +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.asFlow +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.stereotype.Repository + +@Repository +class ReviewQueryRepository( + private val databaseClient: DatabaseClient, +) { + suspend fun findBy(command: GetReviewsCommand): List { + val binding = mutableMapOf() + val query = """ + SELECT r.* + FROM review r, ebook e + WHERE r.ebook_id = e.ebook_id + ${ + command.ebookId?.let { + binding["ebookId"] = it + "AND r.ebook_id = :ebookId" + } ?: "" + } + ${ + command.memberId?.let { + binding["memberId"] = it + "AND e.selling_member_id = :memberId" + } ?: "" + } + ORDER BY r.written_date DESC + OFFSET ${command.offset} LIMIT ${command.limit}; + """.trimIndent() + + return databaseClient + .sql(query) + .bindValues(binding) + .map { row -> mapToDomain(row) } + .all() + .asFlow() + .toList() + } + + private fun mapToDomain(row: Readable) = Review( + id = row.get("review_id", UUID::class.java)!!, + rating = row.get("rating", BigInteger::class.java)!!.toInt(), + content = row.get("content", String::class.java)!!, + ebookId = row.get("ebook_id", UUID::class.java)!!, + writerMemberId = row.get("writer_member_id", UUID::class.java)!!, + writtenDate = row.get("written_date", Instant::class.java)!!, + modifiedDate = row.get("modified_date", Instant::class.java)!!, + ) + + +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/repository/ReviewRepository.kt b/src/main/kotlin/com/devooks/backend/review/v1/repository/ReviewRepository.kt new file mode 100644 index 0000000..0223b8d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/repository/ReviewRepository.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.review.v1.repository + +import com.devooks.backend.review.v1.entity.ReviewEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface ReviewRepository : CoroutineCrudRepository { + suspend fun existsByEbookIdAndWriterMemberId(ebookId: UUID, writerMemberId: UUID): Boolean +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewCommentEventService.kt b/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewCommentEventService.kt new file mode 100644 index 0000000..8ce793e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewCommentEventService.kt @@ -0,0 +1,32 @@ +package com.devooks.backend.review.v1.service + +import com.devooks.backend.ebook.v1.service.EbookService +import com.devooks.backend.member.v1.service.MemberService +import com.devooks.backend.notification.v1.domain.event.CreateReviewCommentEvent +import com.devooks.backend.review.v1.domain.ReviewComment +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class ReviewCommentEventService( + private val memberService: MemberService, + private val reviewService: ReviewService, + private val ebookService: EbookService, + private val publisher: ApplicationEventPublisher, +) { + + suspend fun publish(reviewComment: ReviewComment) { + val member = memberService.findById(reviewComment.writerMemberId) + val review = reviewService.findById(reviewComment.reviewId) + val ebook = ebookService.findById(review.ebookId) + val createReviewCommentEvent = CreateReviewCommentEvent( + reviewCommentId = reviewComment.id, + reviewId = review.id!!, + commenterName = member.nickname, + ebookId = ebook.id, + writtenDate = reviewComment.writtenDate, + receiverId = review.writerMemberId + ) + publisher.publishEvent(createReviewCommentEvent) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewCommentService.kt b/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewCommentService.kt new file mode 100644 index 0000000..03d8051 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewCommentService.kt @@ -0,0 +1,60 @@ +package com.devooks.backend.review.v1.service + +import com.devooks.backend.review.v1.domain.ReviewComment +import com.devooks.backend.review.v1.dto.CreateReviewCommentCommand +import com.devooks.backend.review.v1.dto.DeleteReviewCommentCommand +import com.devooks.backend.review.v1.dto.GetReviewCommentsCommand +import com.devooks.backend.review.v1.dto.ModifyReviewCommentCommand +import com.devooks.backend.review.v1.entity.ReviewCommentEntity +import com.devooks.backend.review.v1.error.ReviewError +import com.devooks.backend.review.v1.repository.ReviewCommentRepository +import java.time.Instant +import java.util.* +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +@Service +class ReviewCommentService( + private val reviewCommentRepository: ReviewCommentRepository, +) { + suspend fun create(command: CreateReviewCommentCommand): ReviewComment { + val entity = ReviewCommentEntity( + reviewId = command.reviewId, + content = command.content, + writerMemberId = command.requesterId, + ) + return reviewCommentRepository.save(entity).toDomain() + } + + suspend fun get(command: GetReviewCommentsCommand): List = + reviewCommentRepository + .findAllByReviewId(command.reviewId, command.pageable) + .map { it.toDomain() } + .toList() + + suspend fun modify(command: ModifyReviewCommentCommand): ReviewComment = + findBy(command.commentId) + .also { reviewComment -> validateRequesterId(reviewComment, command.requesterId) } + .copy(content = command.content, modifiedDate = Instant.now()) + .let { reviewCommentRepository.save(it) } + .toDomain() + + + suspend fun delete(command: DeleteReviewCommentCommand) { + findBy(command.commentId) + .also { comment -> validateRequesterId(comment, command.requesterId) } + .also { comment -> reviewCommentRepository.delete(comment) } + } + + private fun validateRequesterId(reviewComment: ReviewCommentEntity, requesterId: UUID) { + reviewComment + .takeIf { it.writerMemberId == requesterId } + ?: throw ReviewError.FORBIDDEN_MODIFY_REVIEW_COMMENT.exception + } + + private suspend fun findBy(reviewCommentId: UUID): ReviewCommentEntity = + reviewCommentRepository + .findById(reviewCommentId) + ?: throw ReviewError.NOT_FOUND_REVIEW_COMMENT.exception +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewEventService.kt b/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewEventService.kt new file mode 100644 index 0000000..14b536b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewEventService.kt @@ -0,0 +1,30 @@ +package com.devooks.backend.review.v1.service + +import com.devooks.backend.ebook.v1.service.EbookService +import com.devooks.backend.member.v1.service.MemberService +import com.devooks.backend.notification.v1.domain.event.CreateReviewEvent +import com.devooks.backend.review.v1.domain.Review +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class ReviewEventService( + private val memberService: MemberService, + private val ebookService: EbookService, + private val publisher: ApplicationEventPublisher, +) { + + suspend fun publish(review: Review) { + val member = memberService.findById(review.writerMemberId) + val ebook = ebookService.findById(review.ebookId) + val createReviewEvent = CreateReviewEvent( + reviewId = review.id, + reviewerName = member.nickname, + ebookId = ebook.id, + ebookTitle = ebook.title, + writtenDate = review.writtenDate, + receiverId = ebook.sellingMemberId + ) + publisher.publishEvent(createReviewEvent) + } +} diff --git a/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewService.kt b/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewService.kt new file mode 100644 index 0000000..3d43501 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/review/v1/service/ReviewService.kt @@ -0,0 +1,73 @@ +package com.devooks.backend.review.v1.service + +import com.devooks.backend.review.v1.domain.Review +import com.devooks.backend.review.v1.dto.CreateReviewCommand +import com.devooks.backend.review.v1.dto.CreateReviewCommentCommand +import com.devooks.backend.review.v1.dto.DeleteReviewCommand +import com.devooks.backend.review.v1.dto.GetReviewsCommand +import com.devooks.backend.review.v1.dto.ModifyReviewCommand +import com.devooks.backend.review.v1.entity.ReviewEntity +import com.devooks.backend.review.v1.error.ReviewError +import com.devooks.backend.review.v1.repository.ReviewQueryRepository +import com.devooks.backend.review.v1.repository.ReviewRepository +import java.time.Instant +import java.util.* +import org.springframework.stereotype.Service + +@Service +class ReviewService( + private val reviewRepository: ReviewRepository, + private val reviewQueryRepository: ReviewQueryRepository, +) { + suspend fun create(command: CreateReviewCommand): Review { + validateCreateReview(command) + val entity = ReviewEntity( + rating = command.rating, + content = command.content, + ebookId = command.ebookId, + writerMemberId = command.requesterId, + ) + return reviewRepository.save(entity).toDomain() + } + + suspend fun get(command: GetReviewsCommand): List = + reviewQueryRepository.findBy(command) + + suspend fun modify(command: ModifyReviewCommand): Review = + findById(command.reviewId) + .also { review -> validateRequesterId(review, command.requesterId) } + .copy(rating = command.rating, content = command.content, modifiedDate = Instant.now()) + .let { reviewRepository.save(it) } + .toDomain() + + suspend fun delete(command: DeleteReviewCommand) { + findById(command.reviewId) + .also { review -> validateRequesterId(review, command.requesterId) } + .also { review -> reviewRepository.delete(review) } + } + + private fun validateRequesterId( + review: ReviewEntity, + requesterId: UUID, + ) { + review + .takeIf { it.writerMemberId == requesterId } + ?: throw ReviewError.FORBIDDEN_MODIFY_REVIEW.exception + } + + suspend fun validate(command: CreateReviewCommentCommand) { + findById(command.reviewId) + } + + suspend fun findById(reviewId: UUID): ReviewEntity = + reviewRepository + .findById(reviewId) + ?: throw ReviewError.NOT_FOUND_REVIEW.exception + + private suspend fun validateCreateReview(command: CreateReviewCommand) { + reviewRepository + .existsByEbookIdAndWriterMemberId(command.ebookId, command.requesterId) + .takeIf { it.not() } + ?: throw ReviewError.DUPLICATE_REVIEW.exception + } +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryController.kt b/src/main/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryController.kt new file mode 100644 index 0000000..e9fec79 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryController.kt @@ -0,0 +1,87 @@ +package com.devooks.backend.service.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.service.v1.domain.ServiceInquiry +import com.devooks.backend.service.v1.domain.ServiceInquiryImage +import com.devooks.backend.service.v1.dto.command.CreateServiceInquiryCommand +import com.devooks.backend.service.v1.dto.command.GetServiceInquiriesCommand +import com.devooks.backend.service.v1.dto.command.ModifyServiceInquiryCommand +import com.devooks.backend.service.v1.dto.request.CreateServiceInquiryRequest +import com.devooks.backend.service.v1.dto.request.ModifyServiceInquiryRequest +import com.devooks.backend.service.v1.dto.response.CreateServiceInquiryResponse +import com.devooks.backend.service.v1.dto.response.GetServiceInquiriesResponse +import com.devooks.backend.service.v1.dto.response.GetServiceInquiriesResponse.Companion.toGetServiceInquiriesResponse +import com.devooks.backend.service.v1.dto.response.ModifyServiceInquiryResponse +import com.devooks.backend.service.v1.dto.response.ServiceInquiryResponse +import com.devooks.backend.service.v1.service.ServiceInquiryImageService +import com.devooks.backend.service.v1.service.ServiceInquiryService +import java.util.* +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/service-inquiries") +class ServiceInquiryController( + private val tokenService: TokenService, + private val serviceInquiryService: ServiceInquiryService, + private val serviceInquiryImageService: ServiceInquiryImageService, +) { + + @Transactional + @PostMapping + suspend fun createServiceInquiry( + @RequestBody + request: CreateServiceInquiryRequest, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): CreateServiceInquiryResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command: CreateServiceInquiryCommand = request.toCommand(requesterId) + val serviceInquiry: ServiceInquiry = serviceInquiryService.create(command) + val serviceInquiryImageList: List = + command.imageIdList?.let { serviceInquiryImageService.save(it, serviceInquiry) } ?: listOf() + return CreateServiceInquiryResponse(ServiceInquiryResponse(serviceInquiry, serviceInquiryImageList)) + } + + @GetMapping + suspend fun getServiceInquiries( + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): GetServiceInquiriesResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command = GetServiceInquiriesCommand(page = page, count = count, requesterId = requesterId) + return serviceInquiryService.get(command).toGetServiceInquiriesResponse() + } + + @Transactional + @PatchMapping("/{serviceInquiryId}") + suspend fun modifyServiceInquiry( + @PathVariable("serviceInquiryId", required = false) + serviceInquiryId: String, + @RequestBody + request: ModifyServiceInquiryRequest, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): ModifyServiceInquiryResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: ModifyServiceInquiryCommand = request.toCommand(serviceInquiryId, requesterId) + val serviceInquiry: ServiceInquiry = serviceInquiryService.modify(command) + val serviceInquiryImageList: List = serviceInquiryImageService.modify(command, serviceInquiry) + return ModifyServiceInquiryResponse(ServiceInquiryResponse(serviceInquiry, serviceInquiryImageList)) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryImagesController.kt b/src/main/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryImagesController.kt new file mode 100644 index 0000000..04d97c2 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryImagesController.kt @@ -0,0 +1,40 @@ +package com.devooks.backend.service.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.service.v1.domain.ServiceInquiryImage +import com.devooks.backend.service.v1.dto.command.SaveServiceInquiryImagesCommand +import com.devooks.backend.service.v1.dto.request.SaveServiceInquiryImagesRequest +import com.devooks.backend.service.v1.dto.response.SaveServiceInquiryImagesResponse +import com.devooks.backend.service.v1.dto.response.SaveServiceInquiryImagesResponse.Companion.toSaveServiceInquiryImagesResponse +import com.devooks.backend.service.v1.service.ServiceInquiryImageService +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/service-inquiries/images") +class ServiceInquiryImagesController( + private val serviceInquiryImageService: ServiceInquiryImageService, + private val tokenService: TokenService, +) { + + @Transactional + @PostMapping + suspend fun saveServiceInquiryImages( + @RequestBody + request: SaveServiceInquiryImagesRequest, + @RequestHeader(AUTHORIZATION, required = false, defaultValue = "") + authorization: String, + ): SaveServiceInquiryImagesResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: SaveServiceInquiryImagesCommand = request.toCommand(requesterId) + val imageList: List = serviceInquiryImageService.save(command.imageList, requesterId) + return imageList.toSaveServiceInquiryImagesResponse() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/service/v1/domain/InquiryProcessingStatus.kt b/src/main/kotlin/com/devooks/backend/service/v1/domain/InquiryProcessingStatus.kt new file mode 100644 index 0000000..ffeb9d1 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/domain/InquiryProcessingStatus.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.service.v1.domain + +import com.devooks.backend.service.v1.error.ServiceInquiryError + +enum class InquiryProcessingStatus { + WAITING, PROGRESS, COMPLETED; + + companion object { + fun String.toInquiryProcessingStatus(): InquiryProcessingStatus = + runCatching { + InquiryProcessingStatus.valueOf(this) + }.getOrElse { + throw ServiceInquiryError.FAIL_MAP_TO_INQUIRY_PROCESSING_STATUS.exception + } + } +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/domain/ServiceInquiry.kt b/src/main/kotlin/com/devooks/backend/service/v1/domain/ServiceInquiry.kt new file mode 100644 index 0000000..b6c6f88 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/domain/ServiceInquiry.kt @@ -0,0 +1,24 @@ +package com.devooks.backend.service.v1.domain + +import com.devooks.backend.service.v1.dto.command.ModifyServiceInquiryCommand +import java.time.Instant +import java.time.Instant.now +import java.util.* + +data class ServiceInquiry( + val id: UUID, + val title: String, + val content: String, + val createdDate: Instant, + val modifiedDate: Instant, + val inquiryProcessingStatus: InquiryProcessingStatus, + val writerMemberId: UUID, +) { + fun modify(command: ModifyServiceInquiryCommand): ServiceInquiry { + return copy( + title = command.title ?: this.title, + content = command.content ?: this.content, + modifiedDate = now() + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/domain/ServiceInquiryImage.kt b/src/main/kotlin/com/devooks/backend/service/v1/domain/ServiceInquiryImage.kt new file mode 100644 index 0000000..1ea3b32 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/domain/ServiceInquiryImage.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.service.v1.domain + +import java.nio.file.Path +import java.util.* + +class ServiceInquiryImage( + val id: UUID, + val imagePath: Path, + val order: Int, + val uploadMemberId: UUID, + val serviceInquiryId: UUID? +) diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/ServiceInquiryImageDto.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/ServiceInquiryImageDto.kt new file mode 100644 index 0000000..72b1234 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/ServiceInquiryImageDto.kt @@ -0,0 +1,20 @@ +package com.devooks.backend.service.v1.dto + +import com.devooks.backend.service.v1.domain.ServiceInquiryImage +import java.util.* +import kotlin.io.path.pathString + +data class ServiceInquiryImageDto( + val id: UUID, + val imagePath: String, + val order: Int, +) { + companion object { + fun ServiceInquiryImage.toDto() = + ServiceInquiryImageDto( + id = id, + imagePath = imagePath.pathString, + order = order + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/ServiceInquiryView.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/ServiceInquiryView.kt new file mode 100644 index 0000000..1593b40 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/ServiceInquiryView.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.service.v1.dto + +import com.devooks.backend.service.v1.domain.InquiryProcessingStatus +import java.time.Instant +import java.util.* + +data class ServiceInquiryView( + val id: UUID, + val title: String, + val content: String, + val createdDate: Instant, + val modifiedDate: Instant, + val inquiryProcessingStatus: InquiryProcessingStatus, + val writerMemberId: UUID, + val imageList: List, +) diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/command/CreateServiceInquiryCommand.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/command/CreateServiceInquiryCommand.kt new file mode 100644 index 0000000..4d8db6a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/command/CreateServiceInquiryCommand.kt @@ -0,0 +1,10 @@ +package com.devooks.backend.service.v1.dto.command + +import java.util.* + +class CreateServiceInquiryCommand( + val title: String, + val content: String, + val imageIdList: List?, + val requesterId: UUID +) diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/command/GetServiceInquiriesCommand.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/command/GetServiceInquiriesCommand.kt new file mode 100644 index 0000000..395bfbc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/command/GetServiceInquiriesCommand.kt @@ -0,0 +1,24 @@ +package com.devooks.backend.service.v1.dto.command + +import com.devooks.backend.common.dto.Paging +import java.util.* + +class GetServiceInquiriesCommand( + val requesterId: UUID, + private val paging: Paging, +) { + constructor( + page: String, + count: String, + requesterId: UUID, + ) : this( + requesterId = requesterId, + paging = Paging(page, count) + ) + + val offset: Int + get() = paging.offset + + val limit: Int + get() = paging.limit +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/command/ModifyServiceInquiryCommand.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/command/ModifyServiceInquiryCommand.kt new file mode 100644 index 0000000..53e396f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/command/ModifyServiceInquiryCommand.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.service.v1.dto.command + +import java.util.* + +class ModifyServiceInquiryCommand( + val serviceInquiryId: UUID, + val title: String?, + val content: String?, + val imageIdList: List?, + val requesterId: UUID, +) { + val isChangedServiceInquiry = title != null || content != null + val isChangedImageList = imageIdList != null +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/command/SaveServiceInquiryImagesCommand.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/command/SaveServiceInquiryImagesCommand.kt new file mode 100644 index 0000000..b5c0b49 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/command/SaveServiceInquiryImagesCommand.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.service.v1.dto.command + +import com.devooks.backend.common.domain.Image +import java.util.* + +class SaveServiceInquiryImagesCommand( + val imageList: List, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/request/CreateServiceInquiryRequest.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/request/CreateServiceInquiryRequest.kt new file mode 100644 index 0000000..589038b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/request/CreateServiceInquiryRequest.kt @@ -0,0 +1,21 @@ +package com.devooks.backend.service.v1.dto.request + +import com.devooks.backend.service.v1.dto.command.CreateServiceInquiryCommand +import com.devooks.backend.service.v1.error.validateServiceInquiryContent +import com.devooks.backend.service.v1.error.validateServiceInquiryImageIdList +import com.devooks.backend.service.v1.error.validateServiceInquiryTitle +import java.util.* + +data class CreateServiceInquiryRequest( + val title: String?, + val content: String?, + val imageIdList: List?, +) { + fun toCommand(requesterId: UUID): CreateServiceInquiryCommand = + CreateServiceInquiryCommand( + title = title.validateServiceInquiryTitle(), + content = content.validateServiceInquiryContent(), + imageIdList = imageIdList?.validateServiceInquiryImageIdList(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/request/ModifyServiceInquiryRequest.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/request/ModifyServiceInquiryRequest.kt new file mode 100644 index 0000000..6eab224 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/request/ModifyServiceInquiryRequest.kt @@ -0,0 +1,46 @@ +package com.devooks.backend.service.v1.dto.request + +import com.devooks.backend.service.v1.dto.command.ModifyServiceInquiryCommand +import com.devooks.backend.service.v1.error.ServiceInquiryError +import com.devooks.backend.service.v1.error.validateServiceInquiryContent +import com.devooks.backend.service.v1.error.validateServiceInquiryId +import com.devooks.backend.service.v1.error.validateServiceInquiryImageIdList +import com.devooks.backend.service.v1.error.validateServiceInquiryTitle +import java.util.* + +data class ModifyServiceInquiryRequest( + val serviceInquiry: ServiceInquiry?, + val isChanged: IsChanged?, +) { + + data class ServiceInquiry( + val title: String? = null, + val content: String? = null, + val imageIdList: List? = null, + ) + + data class IsChanged( + val title: Boolean? = null, + val content: Boolean? = null, + val imageIdList: Boolean? = null, + ) + + fun toCommand(serviceInquiryId: String, requesterId: UUID) = + if (isChanged != null) { + if (serviceInquiry != null) { + ModifyServiceInquiryCommand( + serviceInquiryId.validateServiceInquiryId(), + if (isChanged.title == true) serviceInquiry.title.validateServiceInquiryTitle() else null, + if (isChanged.content == true) serviceInquiry.content.validateServiceInquiryContent() else null, + if (isChanged.imageIdList != null) serviceInquiry.imageIdList?.validateServiceInquiryImageIdList() else null, + requesterId + + ) + } else { + throw ServiceInquiryError.REQUIRED_IS_CHANGED_FOR_MODIFY.exception + } + } else { + throw ServiceInquiryError.REQUIRED_SERVICE_INQUIRY_FOR_MODIFY.exception + } + +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/request/SaveServiceInquiryImagesRequest.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/request/SaveServiceInquiryImagesRequest.kt new file mode 100644 index 0000000..f77a5ba --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/request/SaveServiceInquiryImagesRequest.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.service.v1.dto.request + +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.common.error.validateImages +import com.devooks.backend.service.v1.dto.command.SaveServiceInquiryImagesCommand +import java.util.* + +data class SaveServiceInquiryImagesRequest( + val imageList: List?, +) { + fun toCommand(requesterId: UUID) = + SaveServiceInquiryImagesCommand( + imageList = imageList.validateImages(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/response/CreateServiceInquiryResponse.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/CreateServiceInquiryResponse.kt new file mode 100644 index 0000000..27ec2be --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/CreateServiceInquiryResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.service.v1.dto.response + +data class CreateServiceInquiryResponse( + val serviceInquiry: ServiceInquiryResponse +) diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/response/GetServiceInquiriesResponse.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/GetServiceInquiriesResponse.kt new file mode 100644 index 0000000..a1fee86 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/GetServiceInquiriesResponse.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.service.v1.dto.response + +import com.devooks.backend.service.v1.dto.ServiceInquiryView + +data class GetServiceInquiriesResponse( + val serviceInquiryList: List +) { + companion object { + fun List.toGetServiceInquiriesResponse() = + GetServiceInquiriesResponse(this) + } +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/response/ModifyServiceInquiryResponse.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/ModifyServiceInquiryResponse.kt new file mode 100644 index 0000000..adbcb3b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/ModifyServiceInquiryResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.service.v1.dto.response + +data class ModifyServiceInquiryResponse( + val serviceInquiry: ServiceInquiryResponse, +) diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/response/SaveServiceInquiryImagesResponse.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/SaveServiceInquiryImagesResponse.kt new file mode 100644 index 0000000..f2ac4c3 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/SaveServiceInquiryImagesResponse.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.service.v1.dto.response + +import com.devooks.backend.service.v1.domain.ServiceInquiryImage +import com.devooks.backend.service.v1.dto.ServiceInquiryImageDto +import com.devooks.backend.service.v1.dto.ServiceInquiryImageDto.Companion.toDto + +data class SaveServiceInquiryImagesResponse( + val imageList: List +) { + companion object { + fun List.toSaveServiceInquiryImagesResponse() = + SaveServiceInquiryImagesResponse(map { it.toDto() }) + } +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/dto/response/ServiceInquiryResponse.kt b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/ServiceInquiryResponse.kt new file mode 100644 index 0000000..918072e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/dto/response/ServiceInquiryResponse.kt @@ -0,0 +1,34 @@ +package com.devooks.backend.service.v1.dto.response + +import com.devooks.backend.service.v1.domain.InquiryProcessingStatus +import com.devooks.backend.service.v1.domain.ServiceInquiry +import com.devooks.backend.service.v1.domain.ServiceInquiryImage +import com.devooks.backend.service.v1.dto.ServiceInquiryImageDto +import com.devooks.backend.service.v1.dto.ServiceInquiryImageDto.Companion.toDto +import java.time.Instant +import java.util.* + +data class ServiceInquiryResponse( + val id: UUID, + val title: String, + val content: String, + val createdDate: Instant, + val modifiedDate: Instant, + val inquiryProcessingStatus: InquiryProcessingStatus, + val writerMemberId: UUID, + val imageList: List, +) { + constructor( + serviceInquiry: ServiceInquiry, + serviceInquiryImageList: List, + ) : this( + id = serviceInquiry.id, + title = serviceInquiry.title, + content = serviceInquiry.content, + createdDate = serviceInquiry.createdDate, + modifiedDate = serviceInquiry.modifiedDate, + inquiryProcessingStatus = serviceInquiry.inquiryProcessingStatus, + writerMemberId = serviceInquiry.writerMemberId, + imageList = serviceInquiryImageList.map { it.toDto() }.sortedBy { it.order } + ) +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/entity/ServiceInquiryEntity.kt b/src/main/kotlin/com/devooks/backend/service/v1/entity/ServiceInquiryEntity.kt new file mode 100644 index 0000000..abcfb84 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/entity/ServiceInquiryEntity.kt @@ -0,0 +1,52 @@ +package com.devooks.backend.service.v1.entity + +import com.devooks.backend.service.v1.domain.InquiryProcessingStatus +import com.devooks.backend.service.v1.domain.ServiceInquiry +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "service_inquiry") +data class ServiceInquiryEntity( + @Id + @Column(value = "service_inquiry_id") + @get:JvmName("serviceInquiryId") + val id: UUID? = null, + val title: String, + val content: String, + val writerMemberId: UUID, + val inquiryProcessingStatus: InquiryProcessingStatus = InquiryProcessingStatus.WAITING, + val createdDate: Instant = Instant.now(), + val modifiedDate: Instant = createdDate, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + ServiceInquiry( + id = id!!, + title = title, + content = content, + writerMemberId = writerMemberId, + inquiryProcessingStatus = inquiryProcessingStatus, + createdDate = createdDate, + modifiedDate = modifiedDate, + ) + + companion object { + fun ServiceInquiry.toEntity() = + ServiceInquiryEntity( + id = id, + title = title, + content = content, + writerMemberId = writerMemberId, + inquiryProcessingStatus = inquiryProcessingStatus, + createdDate = createdDate, + modifiedDate = modifiedDate, + ) + } +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/entity/ServiceInquiryImageEntity.kt b/src/main/kotlin/com/devooks/backend/service/v1/entity/ServiceInquiryImageEntity.kt new file mode 100644 index 0000000..c9d011c --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/entity/ServiceInquiryImageEntity.kt @@ -0,0 +1,34 @@ +package com.devooks.backend.service.v1.entity + +import com.devooks.backend.service.v1.domain.ServiceInquiryImage +import java.util.* +import kotlin.io.path.Path +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "service_inquiry_image") +data class ServiceInquiryImageEntity( + @Id + @Column("service_inquiry_image_id") + @get:JvmName("serviceInquiryImageId") + val id: UUID? = null, + val imagePath: String, + val imageOrder: Int, + val uploadMemberId: UUID, + val serviceInquiryId: UUID? = null, +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + ServiceInquiryImage( + id = id!!, + imagePath = Path(imagePath), + order = imageOrder, + uploadMemberId = uploadMemberId, + serviceInquiryId = serviceInquiryId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/error/ServiceInquiryError.kt b/src/main/kotlin/com/devooks/backend/service/v1/error/ServiceInquiryError.kt new file mode 100644 index 0000000..cd33a02 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/error/ServiceInquiryError.kt @@ -0,0 +1,25 @@ +package com.devooks.backend.service.v1.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus + +enum class ServiceInquiryError(val exception: GeneralException) { + // 400 + REQUIRED_SERVICE_INQUIRY_TITLE(GeneralException("SERVICE-400-1", HttpStatus.BAD_REQUEST, "서비스 문의 제목이 반드시 필요합니다.")), + REQUIRED_SERVICE_INQUIRY_CONTENT(GeneralException("SERVICE-400-2", HttpStatus.BAD_REQUEST, "서비스 문의 내용이 반드시 필요합니다.")), + INVALID_SERVICE_INQUIRY_IMAGE_ID(GeneralException("SERVICE-400-3", HttpStatus.BAD_REQUEST, "잘못된 형식의 사진 식별자입니다.")), + REQUIRED_SERVICE_INQUIRY_FOR_MODIFY(GeneralException("SERVICE-400-4", HttpStatus.BAD_REQUEST, "서비스 문의가 반드시 필요합니다.")), + REQUIRED_IS_CHANGED_FOR_MODIFY(GeneralException("SERVICE-400-5", HttpStatus.BAD_REQUEST, "수정 여부가 반드시 필요합니다.")), + REQUIRED_SERVICE_INQUIRY_ID(GeneralException("SERVICE-400-6", HttpStatus.BAD_REQUEST, "서비스 문의 식별자가 반드시 필요합니다.")), + INVALID_SERVICE_INQUIRY_ID(GeneralException("SERVICE-400-7", HttpStatus.BAD_REQUEST, "잘못된 형식의 서비스 문의 식별자입니다.")), + + // 403 + FORBIDDEN_REGISTER_SERVICE_INQUIRY_TO_IMAGE(GeneralException("SERVICE-403-1", HttpStatus.FORBIDDEN, "자신이 등록한 사진만 서비스 문의에 등록할 수 있습니다.")), + FORBIDDEN_MODIFY_SERVICE_INQUIRY(GeneralException("SERVICE-403-2", HttpStatus.FORBIDDEN, "자신이 등록한 서비스 문의만 수정할 수 있습니다.")), + + // 404 + NOT_FOUND_SERVICE_INQUIRY(GeneralException("SERVICE-404-1", HttpStatus.NOT_FOUND, "서비스 문의를 찾을 수 없습니다.")), + + // 500 + FAIL_MAP_TO_INQUIRY_PROCESSING_STATUS(GeneralException("SERVICE-500-1", HttpStatus.INTERNAL_SERVER_ERROR, "서비스 문의 조회를 실패했습니다.")), +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/service/v1/error/ServiceInquiryValidation.kt b/src/main/kotlin/com/devooks/backend/service/v1/error/ServiceInquiryValidation.kt new file mode 100644 index 0000000..4ac3fdf --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/error/ServiceInquiryValidation.kt @@ -0,0 +1,18 @@ +package com.devooks.backend.service.v1.error + +import com.devooks.backend.common.error.validateNotBlank +import com.devooks.backend.common.error.validateUUID +import java.util.* + +fun String?.validateServiceInquiryTitle(): String = + validateNotBlank(ServiceInquiryError.REQUIRED_SERVICE_INQUIRY_TITLE.exception) + +fun String?.validateServiceInquiryContent(): String = + validateNotBlank(ServiceInquiryError.REQUIRED_SERVICE_INQUIRY_CONTENT.exception) + +fun List.validateServiceInquiryImageIdList(): List = + map { it.validateUUID(ServiceInquiryError.INVALID_SERVICE_INQUIRY_IMAGE_ID.exception) } + +fun String?.validateServiceInquiryId(): UUID = + validateNotBlank(ServiceInquiryError.REQUIRED_SERVICE_INQUIRY_ID.exception) + .validateUUID(ServiceInquiryError.INVALID_SERVICE_INQUIRY_ID.exception) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/service/v1/repository/ServiceInquiryImageRepository.kt b/src/main/kotlin/com/devooks/backend/service/v1/repository/ServiceInquiryImageRepository.kt new file mode 100644 index 0000000..0fc6928 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/repository/ServiceInquiryImageRepository.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.service.v1.repository + +import com.devooks.backend.service.v1.entity.ServiceInquiryImageEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface ServiceInquiryImageRepository : CoroutineCrudRepository { + suspend fun findAllByServiceInquiryId(serviceInquiryId: UUID): List +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/repository/ServiceInquiryQueryRepository.kt b/src/main/kotlin/com/devooks/backend/service/v1/repository/ServiceInquiryQueryRepository.kt new file mode 100644 index 0000000..ce2682e --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/repository/ServiceInquiryQueryRepository.kt @@ -0,0 +1,75 @@ +package com.devooks.backend.service.v1.repository + +import com.devooks.backend.service.v1.domain.InquiryProcessingStatus.Companion.toInquiryProcessingStatus +import com.devooks.backend.service.v1.dto.ServiceInquiryImageDto +import com.devooks.backend.service.v1.dto.ServiceInquiryView +import com.devooks.backend.service.v1.dto.command.GetServiceInquiriesCommand +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.r2dbc.spi.Readable +import java.time.Instant +import java.util.* +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.asFlow +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.stereotype.Repository + +@Repository +class ServiceInquiryQueryRepository( + private val databaseClient: DatabaseClient, +) { + private val objectMapper: ObjectMapper = ObjectMapper() + + suspend fun findBy(command: GetServiceInquiriesCommand): List { + val binding = mutableMapOf() + binding["memberId"] = command.requesterId + val query = """ + SELECT service_inquiry_id AS id, + title, + content, + created_date, + modified_date, + inquiry_processing_status, + writer_member_id, + (SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(si.*))) + FROM service_inquiry_image si + WHERE s.service_inquiry_id = si.service_inquiry_id) AS image_list + FROM service_inquiry s + WHERE writer_member_id = :memberId + ORDER BY s.created_date DESC + OFFSET ${command.offset} LIMIT ${command.limit} + """.trimIndent() + + return databaseClient + .sql(query) + .bindValues(binding) + .map { row -> mapToServiceInquiryView(row) } + .all() + .asFlow() + .toList() + } + + private fun mapToServiceInquiryView(row: Readable) = + ServiceInquiryView( + id = row.get("id", UUID::class.java)!!, + title = row.get("title", String::class.java)!!, + content = row.get("content", String::class.java)!!, + createdDate = row.get("created_date", Instant::class.java)!!, + modifiedDate = row.get("modified_date", Instant::class.java)!!, + inquiryProcessingStatus = row.get("inquiry_processing_status", String::class.java)!! + .toInquiryProcessingStatus(), + writerMemberId = row.get("writer_member_id", UUID::class.java)!!, + imageList = row.get("image_list", String::class.java)?.let { + val imageList = objectMapper.readValue>>(it) + imageList.map { image -> + ServiceInquiryImageDto( + id = UUID.fromString(image["service_inquiry_image_id"]!!), + imagePath = image["image_path"]!!, + order = image["image_order"]!!.toInt() + ) + } + } ?: listOf() + ) + + +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/repository/ServiceInquiryRepository.kt b/src/main/kotlin/com/devooks/backend/service/v1/repository/ServiceInquiryRepository.kt new file mode 100644 index 0000000..13b001a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/repository/ServiceInquiryRepository.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.service.v1.repository + +import com.devooks.backend.service.v1.entity.ServiceInquiryEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface ServiceInquiryRepository : CoroutineCrudRepository diff --git a/src/main/kotlin/com/devooks/backend/service/v1/service/ServiceInquiryImageService.kt b/src/main/kotlin/com/devooks/backend/service/v1/service/ServiceInquiryImageService.kt new file mode 100644 index 0000000..461566f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/service/ServiceInquiryImageService.kt @@ -0,0 +1,92 @@ +package com.devooks.backend.service.v1.service + +import com.devooks.backend.BackendApplication.Companion.SERVICE_INQUIRY_IMAGE_ROOT_PATH +import com.devooks.backend.common.domain.Image +import com.devooks.backend.common.utils.saveImage +import com.devooks.backend.service.v1.domain.ServiceInquiry +import com.devooks.backend.service.v1.domain.ServiceInquiryImage +import com.devooks.backend.service.v1.dto.command.ModifyServiceInquiryCommand +import com.devooks.backend.service.v1.entity.ServiceInquiryImageEntity +import com.devooks.backend.service.v1.error.ServiceInquiryError +import com.devooks.backend.service.v1.repository.ServiceInquiryImageRepository +import java.util.* +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.count +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +@Service +class ServiceInquiryImageService( + private val serviceInquiryImageRepository: ServiceInquiryImageRepository, +) { + + suspend fun save( + imageList: List, + requesterId: UUID, + ): List = + imageList + .asFlow() + .map { + ServiceInquiryImageEntity( + imagePath = saveImage(it, SERVICE_INQUIRY_IMAGE_ROOT_PATH), + imageOrder = it.order, + uploadMemberId = requesterId + ) + } + .let { serviceInquiryImageRepository.saveAll(it) } + .map { it.toDomain() } + .toList() + + suspend fun save( + imageIdList: List, + serviceInquiry: ServiceInquiry, + ): List = + serviceInquiryImageRepository + .findAllById(imageIdList) + .takeIf { imageList -> + imageList.filter { image -> + image.uploadMemberId != serviceInquiry.writerMemberId + }.count() == 0 + } + ?.map { it.copy(serviceInquiryId = serviceInquiry.id) } + ?.let { serviceInquiryImageRepository.saveAll(it) } + ?.map { it.toDomain() } + ?.toList() + ?: throw ServiceInquiryError.FORBIDDEN_REGISTER_SERVICE_INQUIRY_TO_IMAGE.exception + + suspend fun modify( + command: ModifyServiceInquiryCommand, + serviceInquiry: ServiceInquiry, + ): List { + val serviceInquiryImageList = + serviceInquiryImageRepository + .findAllByServiceInquiryId(command.serviceInquiryId) + + val changedServiceInquiryImageList = + if (command.isChangedImageList) { + val changeImageIdList = command.imageIdList!! + + val (deletedImageList, existImageList) = + serviceInquiryImageList + .partition { image -> + changeImageIdList.all { imageId -> + image.id != imageId + } + } + + val newImageList = changeImageIdList.filter { change -> existImageList.none { it.id == change } } + val newServiceInquiryImageList = save(newImageList, serviceInquiry) + + serviceInquiryImageRepository.deleteAll(deletedImageList) + + newServiceInquiryImageList.plus(existImageList.map { it.toDomain() }) + } else { + serviceInquiryImageList.map { it.toDomain() } + } + return changedServiceInquiryImageList.sortedBy { it.order } + } + + +} diff --git a/src/main/kotlin/com/devooks/backend/service/v1/service/ServiceInquiryService.kt b/src/main/kotlin/com/devooks/backend/service/v1/service/ServiceInquiryService.kt new file mode 100644 index 0000000..d2bbda9 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/service/v1/service/ServiceInquiryService.kt @@ -0,0 +1,55 @@ +package com.devooks.backend.service.v1.service + +import com.devooks.backend.service.v1.domain.ServiceInquiry +import com.devooks.backend.service.v1.dto.ServiceInquiryView +import com.devooks.backend.service.v1.dto.command.CreateServiceInquiryCommand +import com.devooks.backend.service.v1.dto.command.GetServiceInquiriesCommand +import com.devooks.backend.service.v1.dto.command.ModifyServiceInquiryCommand +import com.devooks.backend.service.v1.entity.ServiceInquiryEntity +import com.devooks.backend.service.v1.entity.ServiceInquiryEntity.Companion.toEntity +import com.devooks.backend.service.v1.error.ServiceInquiryError +import com.devooks.backend.service.v1.repository.ServiceInquiryQueryRepository +import com.devooks.backend.service.v1.repository.ServiceInquiryRepository +import java.util.* +import org.springframework.stereotype.Service + +@Service +class ServiceInquiryService( + private val serviceInquiryRepository: ServiceInquiryRepository, + private val serviceInquiryQueryRepository: ServiceInquiryQueryRepository, +) { + suspend fun create(command: CreateServiceInquiryCommand): ServiceInquiry { + val serviceInquiryEntity = serviceInquiryRepository.save( + ServiceInquiryEntity( + title = command.title, + content = command.content, + writerMemberId = command.requesterId + ) + ) + return serviceInquiryEntity.toDomain() + } + + suspend fun get(command: GetServiceInquiriesCommand): List = + serviceInquiryQueryRepository.findBy(command) + + suspend fun modify(command: ModifyServiceInquiryCommand): ServiceInquiry { + val serviceInquiry = findBy(command.serviceInquiryId) + return findBy(command.serviceInquiryId) + .takeIf { command.isChangedServiceInquiry } + ?.validate(command) + ?.modify(command) + ?.let { serviceInquiryRepository.save(it.toEntity()).toDomain() } + ?: serviceInquiry + } + + private suspend fun findBy(id: UUID) = + serviceInquiryRepository + .findById(id) + ?.toDomain() + ?: throw ServiceInquiryError.NOT_FOUND_SERVICE_INQUIRY.exception + + private suspend fun ServiceInquiry.validate(command: ModifyServiceInquiryCommand) = + takeIf { it.writerMemberId == command.requesterId } + ?: throw ServiceInquiryError.FORBIDDEN_MODIFY_SERVICE_INQUIRY.exception + +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/controller/TransactionController.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/controller/TransactionController.kt new file mode 100644 index 0000000..d3d2b15 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/controller/TransactionController.kt @@ -0,0 +1,79 @@ +package com.devooks.backend.transaciton.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.ebook.v1.service.EbookService +import com.devooks.backend.transaciton.v1.domain.Transaction +import com.devooks.backend.transaciton.v1.dto.CreateTransactionCommand +import com.devooks.backend.transaciton.v1.dto.CreateTransactionRequest +import com.devooks.backend.transaciton.v1.dto.CreateTransactionResponse +import com.devooks.backend.transaciton.v1.dto.GetBuyHistoriesCommand +import com.devooks.backend.transaciton.v1.dto.GetBuyHistoriesResponse +import com.devooks.backend.transaciton.v1.dto.GetBuyHistoriesResponse.Companion.toGetBuyHistoriesResponse +import com.devooks.backend.transaciton.v1.dto.GetSellHistoriesCommand +import com.devooks.backend.transaciton.v1.dto.GetSellHistoriesResponse +import com.devooks.backend.transaciton.v1.dto.GetSellHistoriesResponse.Companion.toGetSellHistoriesResponse +import com.devooks.backend.transaciton.v1.service.TransactionService +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/transactions") +class TransactionController( + private val transactionService: TransactionService, + private val ebookService: EbookService, + private val tokenService: TokenService, +) { + + @Transactional + @PostMapping + suspend fun createTransaction( + @RequestBody + request: CreateTransactionRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): CreateTransactionResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command: CreateTransactionCommand = request.toCommand(requesterId) + ebookService.validate(command) + val transaction: Transaction = transactionService.create(command) + return CreateTransactionResponse(transaction) + } + + @GetMapping("/buy-histories") + suspend fun getBuyHistories( + @RequestParam(required = false, defaultValue = "") + ebookTitle: String, + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): GetBuyHistoriesResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command = GetBuyHistoriesCommand(ebookTitle, page, count, requesterId) + return transactionService.get(command).toGetBuyHistoriesResponse() + } + + @GetMapping("/sell-histories") + suspend fun getSellHistories( + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): GetSellHistoriesResponse { + val requesterId = tokenService.getMemberId(Authorization(authorization)) + val command = GetSellHistoriesCommand(page, count, requesterId) + return transactionService.get(command).toGetSellHistoriesResponse() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/domain/PaymentMethod.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/domain/PaymentMethod.kt new file mode 100644 index 0000000..582a16d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/domain/PaymentMethod.kt @@ -0,0 +1,29 @@ +package com.devooks.backend.transaciton.v1.domain + +import com.devooks.backend.transaciton.v1.domain.PaymentMethod.BANK_DEPOSIT +import com.devooks.backend.transaciton.v1.domain.PaymentMethod.CREDIT_CARD +import com.devooks.backend.transaciton.v1.domain.PaymentMethod.MOBILE_PHONE +import com.devooks.backend.transaciton.v1.domain.PaymentMethod.REAL_TIME_BANK_TRANSFER +import com.devooks.backend.transaciton.v1.error.TransactionError + +/** + * 결제 방법 + * + * @property CREDIT_CARD 신용카드 + * @property REAL_TIME_BANK_TRANSFER 실시간 계좌이체 + * @property BANK_DEPOSIT 무통장 입금 + * @property MOBILE_PHONE 휴대폰 + * + */ +enum class PaymentMethod { + CREDIT_CARD, REAL_TIME_BANK_TRANSFER, BANK_DEPOSIT, MOBILE_PHONE; + + companion object { + fun String.toPaymentMethod(): PaymentMethod = + runCatching { + PaymentMethod.valueOf(this) + }.getOrElse { + throw TransactionError.INVALID_PAYMENT_METHOD.exception + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/domain/Transaction.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/domain/Transaction.kt new file mode 100644 index 0000000..6f3bdaf --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/domain/Transaction.kt @@ -0,0 +1,13 @@ +package com.devooks.backend.transaciton.v1.domain + +import java.time.Instant +import java.util.* + +class Transaction( + val id: UUID, + val ebookId: UUID, + val price: Int, + val paymentMethod: PaymentMethod, + val transactionDate: Instant, + val buyerMemberId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/CreateTransactionCommand.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/CreateTransactionCommand.kt new file mode 100644 index 0000000..9fea044 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/CreateTransactionCommand.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.transaciton.v1.dto + +import com.devooks.backend.transaciton.v1.domain.PaymentMethod +import java.util.* + +class CreateTransactionCommand( + val ebookId: UUID, + val paymentMethod: PaymentMethod, + val price: Int, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/CreateTransactionRequest.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/CreateTransactionRequest.kt new file mode 100644 index 0000000..0a25078 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/CreateTransactionRequest.kt @@ -0,0 +1,20 @@ +package com.devooks.backend.transaciton.v1.dto + +import com.devooks.backend.ebook.v1.error.validateEbookPrice +import com.devooks.backend.transaciton.v1.error.validatePaymentMethod +import com.devooks.backend.wishlist.v1.error.validateEbookId +import java.util.* + +data class CreateTransactionRequest( + val ebookId: String?, + val paymentMethod: String?, + val price: Int? +) { + fun toCommand(requesterId: UUID): CreateTransactionCommand = + CreateTransactionCommand( + ebookId = ebookId.validateEbookId(), + paymentMethod = paymentMethod.validatePaymentMethod(), + price = price.validateEbookPrice(), + requesterId = requesterId + ) +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/CreateTransactionResponse.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/CreateTransactionResponse.kt new file mode 100644 index 0000000..1f55d4b --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/CreateTransactionResponse.kt @@ -0,0 +1,24 @@ +package com.devooks.backend.transaciton.v1.dto + +import com.devooks.backend.transaciton.v1.domain.PaymentMethod +import com.devooks.backend.transaciton.v1.domain.Transaction +import java.time.Instant +import java.util.* + +data class CreateTransactionResponse( + val transactionId: UUID, + val ebookId: UUID, + val paymentMethod: PaymentMethod, + val price: Int, + val transactionDate: Instant, +) { + constructor( + transaction: Transaction, + ) : this( + transactionId = transaction.id, + ebookId = transaction.ebookId, + paymentMethod = transaction.paymentMethod, + price = transaction.price, + transactionDate = transaction.transactionDate, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetBuyHistoriesCommand.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetBuyHistoriesCommand.kt new file mode 100644 index 0000000..58f5890 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetBuyHistoriesCommand.kt @@ -0,0 +1,28 @@ +package com.devooks.backend.transaciton.v1.dto + +import com.devooks.backend.common.dto.Paging +import java.util.* + +class GetBuyHistoriesCommand( + val ebookTitle: String?, + val requesterId: UUID, + private val paging: Paging, +) { + constructor( + ebookTitle: String, + page: String, + count: String, + requesterId: UUID, + ) : this( + ebookTitle = ebookTitle.takeIf { it.isNotBlank() }?.let { "%$ebookTitle%" }, + requesterId = requesterId, + paging = Paging(page, count) + ) + + val offset: Int + get() = paging.offset + + val limit: Int + get() = paging.limit + +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetBuyHistoriesResponse.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetBuyHistoriesResponse.kt new file mode 100644 index 0000000..b6dc03d --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetBuyHistoriesResponse.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.transaciton.v1.dto + +import com.devooks.backend.transaciton.v1.domain.Transaction +import com.devooks.backend.transaciton.v1.dto.TransactionDto.Companion.toDto + +data class GetBuyHistoriesResponse( + val transactionList: List, +) { + + companion object { + fun List.toGetBuyHistoriesResponse(): GetBuyHistoriesResponse = + GetBuyHistoriesResponse(this.map { it.toDto() }) + } +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetSellHistoriesCommand.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetSellHistoriesCommand.kt new file mode 100644 index 0000000..f35b60a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetSellHistoriesCommand.kt @@ -0,0 +1,24 @@ +package com.devooks.backend.transaciton.v1.dto + +import com.devooks.backend.common.dto.Paging +import java.util.* + +class GetSellHistoriesCommand( + val requesterId: UUID, + private val paging: Paging, +) { + constructor( + page: String, + count: String, + requesterId: UUID, + ) : this( + requesterId = requesterId, + paging = Paging(page, count) + ) + + val offset: Int + get() = paging.offset + + val limit: Int + get() = paging.limit +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetSellHistoriesResponse.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetSellHistoriesResponse.kt new file mode 100644 index 0000000..db12481 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/GetSellHistoriesResponse.kt @@ -0,0 +1,14 @@ +package com.devooks.backend.transaciton.v1.dto + +import com.devooks.backend.transaciton.v1.domain.Transaction +import com.devooks.backend.transaciton.v1.dto.TransactionDto.Companion.toDto + +data class GetSellHistoriesResponse( + val transactionList: List, +) { + + companion object { + fun List.toGetSellHistoriesResponse(): GetSellHistoriesResponse = + GetSellHistoriesResponse(this.map { it.toDto() }) + } +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/TransactionDto.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/TransactionDto.kt new file mode 100644 index 0000000..7980923 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/dto/TransactionDto.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.transaciton.v1.dto + +import com.devooks.backend.transaciton.v1.domain.Transaction +import java.time.Instant +import java.util.* + +data class TransactionDto( + val id: UUID, + val ebookId: UUID, + val transactionDate: Instant, + val price: Int, +) { + companion object { + fun Transaction.toDto(): TransactionDto = + TransactionDto(this.id, this.ebookId, this.transactionDate, this.price) + } +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/entity/TransactionEntity.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/entity/TransactionEntity.kt new file mode 100644 index 0000000..dd9aa1a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/entity/TransactionEntity.kt @@ -0,0 +1,37 @@ +package com.devooks.backend.transaciton.v1.entity + +import com.devooks.backend.transaciton.v1.domain.PaymentMethod +import com.devooks.backend.transaciton.v1.domain.Transaction +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "transaction") +data class TransactionEntity( + @Id + @Column(value = "transaction_id") + @get:JvmName("transactionId") + val id: UUID? = null, + val ebookId: UUID, + val price: Int, + val paymentMethod: PaymentMethod, + val buyerMemberId: UUID, + val transactionDate: Instant = Instant.now(), +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + Transaction( + id = this.id!!, + ebookId = this.ebookId, + price = this.price, + paymentMethod = this.paymentMethod, + transactionDate = this.transactionDate, + buyerMemberId = this.buyerMemberId, + ) +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/error/TransactionError.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/error/TransactionError.kt new file mode 100644 index 0000000..f56bce7 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/error/TransactionError.kt @@ -0,0 +1,23 @@ +package com.devooks.backend.transaciton.v1.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.CONFLICT +import org.springframework.http.HttpStatus.FORBIDDEN + +enum class TransactionError(val exception: GeneralException) { + // 400 + REQUIRED_PAYMENT_METHOD(GeneralException("TRANSACTION-400-1", BAD_REQUEST, "결제 수단이 반드시 필요합니다.")), + INVALID_PAYMENT_METHOD(GeneralException("TRANSACTION-400-2", BAD_REQUEST, "잘못된 형식의 결제 방법 입니다.")), + + // 403 + FORBIDDEN_REVIEW(GeneralException("TRANSACTION-403-1", FORBIDDEN, "구매한 전자책만 리뷰가 가능합니다.")), + + // 409 + DUPLICATE_TRANSACTION(GeneralException("TRANSACTION-409-1", CONFLICT, "이미 구매한 책 입니다.")) + ; + + override fun toString(): String { + return super.toString() + } +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/error/TransactionValidation.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/error/TransactionValidation.kt new file mode 100644 index 0000000..efb3d5a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/error/TransactionValidation.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.transaciton.v1.error + +import com.devooks.backend.common.error.validateNotBlank +import com.devooks.backend.transaciton.v1.domain.PaymentMethod +import com.devooks.backend.transaciton.v1.domain.PaymentMethod.Companion.toPaymentMethod + +fun String?.validatePaymentMethod(): PaymentMethod = + validateNotBlank(TransactionError.REQUIRED_PAYMENT_METHOD.exception) + .toPaymentMethod() \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/repository/TransactionQueryRepository.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/repository/TransactionQueryRepository.kt new file mode 100644 index 0000000..3db0bc8 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/repository/TransactionQueryRepository.kt @@ -0,0 +1,86 @@ +package com.devooks.backend.transaciton.v1.repository + +import com.devooks.backend.transaciton.v1.domain.PaymentMethod.Companion.toPaymentMethod +import com.devooks.backend.transaciton.v1.domain.Transaction +import com.devooks.backend.transaciton.v1.dto.GetBuyHistoriesCommand +import com.devooks.backend.transaciton.v1.dto.GetSellHistoriesCommand +import io.r2dbc.spi.Readable +import java.math.BigInteger +import java.time.Instant +import java.util.* +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.asFlow +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.stereotype.Repository + +@Repository +class TransactionQueryRepository( + private val databaseClient: DatabaseClient, +) { + + suspend fun findBy(command: GetBuyHistoriesCommand): List { + val bindings = mutableMapOf() + val query = """ + SELECT t.* + FROM transaction t, ebook e + WHERE t.ebook_id = e.ebook_id + AND t.buyer_member_id = ${ + command.requesterId.let { + bindings["requesterId"] = it + ":requesterId" + } + } + ${ + command.ebookTitle?.let { + bindings["ebookTitle"] = it + "AND e.title ilike :ebookTitle" + } ?: "" + } + ORDER BY t.transaction_date DESC + OFFSET ${command.offset} LIMIT ${command.limit}; + """.trimIndent() + + return databaseClient + .sql(query) + .bindValues(bindings) + .map { row -> mapToDomain(row) } + .all() + .asFlow() + .toList() + } + + suspend fun findBy(command: GetSellHistoriesCommand): List { + val bindings = mutableMapOf() + val query = """ + SELECT t.* + FROM transaction t, ebook e + WHERE t.ebook_id = e.ebook_id + AND e.selling_member_id = ${ + command.requesterId.let { + bindings["requesterId"] = it + ":requesterId" + } + } + ORDER BY t.transaction_date DESC + OFFSET ${command.offset} LIMIT ${command.limit} + """.trimIndent() + + return databaseClient + .sql(query) + .bindValues(bindings) + .map { row -> mapToDomain(row) } + .all() + .asFlow() + .toList() + } + + private fun mapToDomain(row: Readable) = Transaction( + id = row.get("transaction_id", UUID::class.java)!!, + ebookId = row.get("ebook_id", UUID::class.java)!!, + price = row.get("price", BigInteger::class.java)!!.toInt(), + paymentMethod = row.get("payment_method", String::class.java)!!.toPaymentMethod(), + transactionDate = row.get("transaction_date", Instant::class.java)!!, + buyerMemberId = row.get("buyer_member_id", UUID::class.java)!!, + ) + +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/repository/TransactionRepository.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/repository/TransactionRepository.kt new file mode 100644 index 0000000..d07f464 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/repository/TransactionRepository.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.transaciton.v1.repository + +import com.devooks.backend.transaciton.v1.entity.TransactionEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface TransactionRepository : CoroutineCrudRepository { + + suspend fun existsByEbookIdAndBuyerMemberId(ebookId: UUID, buyerMemberId: UUID): Boolean +} diff --git a/src/main/kotlin/com/devooks/backend/transaciton/v1/service/TransactionService.kt b/src/main/kotlin/com/devooks/backend/transaciton/v1/service/TransactionService.kt new file mode 100644 index 0000000..528f939 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/transaciton/v1/service/TransactionService.kt @@ -0,0 +1,50 @@ +package com.devooks.backend.transaciton.v1.service + +import com.devooks.backend.review.v1.dto.CreateReviewCommand +import com.devooks.backend.transaciton.v1.domain.Transaction +import com.devooks.backend.transaciton.v1.dto.CreateTransactionCommand +import com.devooks.backend.transaciton.v1.dto.GetBuyHistoriesCommand +import com.devooks.backend.transaciton.v1.dto.GetSellHistoriesCommand +import com.devooks.backend.transaciton.v1.entity.TransactionEntity +import com.devooks.backend.transaciton.v1.error.TransactionError +import com.devooks.backend.transaciton.v1.repository.TransactionQueryRepository +import com.devooks.backend.transaciton.v1.repository.TransactionRepository +import org.springframework.stereotype.Service + +@Service +class TransactionService( + private val transactionRepository: TransactionRepository, + private val transactionQueryRepository: TransactionQueryRepository, +) { + suspend fun create(command: CreateTransactionCommand): Transaction { + validateCreateCommand(command) + val entity = TransactionEntity( + ebookId = command.ebookId, + price = command.price, + paymentMethod = command.paymentMethod, + buyerMemberId = command.requesterId + ) + return transactionRepository.save(entity).toDomain() + } + + suspend fun get(command: GetBuyHistoriesCommand): List = + transactionQueryRepository.findBy(command) + + suspend fun get(command: GetSellHistoriesCommand): List = + transactionQueryRepository.findBy(command) + + suspend fun validate(command: CreateReviewCommand) { + transactionRepository + .existsByEbookIdAndBuyerMemberId(command.ebookId, command.requesterId) + .takeIf { it } + ?: throw TransactionError.FORBIDDEN_REVIEW.exception + } + + private suspend fun validateCreateCommand(command: CreateTransactionCommand) { + transactionRepository + .existsByEbookIdAndBuyerMemberId(command.ebookId, command.requesterId) + .takeIf { it.not() } + ?: throw TransactionError.DUPLICATE_TRANSACTION.exception + } + +} diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/controller/WishlistController.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/controller/WishlistController.kt new file mode 100644 index 0000000..b53c779 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/controller/WishlistController.kt @@ -0,0 +1,83 @@ +package com.devooks.backend.wishlist.v1.controller + +import com.devooks.backend.auth.v1.domain.Authorization +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.ebook.v1.domain.Ebook +import com.devooks.backend.ebook.v1.service.EbookService +import com.devooks.backend.wishlist.v1.domain.Wishlist +import com.devooks.backend.wishlist.v1.dto.CreateWishlistCommand +import com.devooks.backend.wishlist.v1.dto.CreateWishlistRequest +import com.devooks.backend.wishlist.v1.dto.CreateWishlistResponse +import com.devooks.backend.wishlist.v1.dto.DeleteWishlistCommand +import com.devooks.backend.wishlist.v1.dto.DeleteWishlistResponse +import com.devooks.backend.wishlist.v1.dto.GetWishlistCommand +import com.devooks.backend.wishlist.v1.dto.GetWishlistResponse +import com.devooks.backend.wishlist.v1.dto.GetWishlistResponse.Companion.toResponse +import com.devooks.backend.wishlist.v1.service.WishlistService +import java.util.* +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/wishlist") +class WishlistController( + private val wishlistService: WishlistService, + private val ebookService: EbookService, + private val tokenService: TokenService, +) { + + @Transactional + @PostMapping + suspend fun createWishlist( + @RequestBody + request: CreateWishlistRequest, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): CreateWishlistResponse { + val requesterId: UUID = tokenService.getMemberId(Authorization(authorization)) + val command: CreateWishlistCommand = request.toCommand(requesterId) + val ebook: Ebook = ebookService.findById(command.ebookId) + val wishlist: Wishlist = wishlistService.create(command, ebook) + return CreateWishlistResponse(wishlist) + } + + @GetMapping + suspend fun getWishlist( + @RequestParam(required = false, defaultValue = "") + categoryIds: List, + @RequestParam(required = false, defaultValue = "") + page: String, + @RequestParam(required = false, defaultValue = "") + count: String, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): GetWishlistResponse { + val memberId = tokenService.getMemberId(Authorization(authorization)) + val command = GetWishlistCommand(memberId, categoryIds, page, count) + return wishlistService.get(command).toResponse() + } + + @Transactional + @DeleteMapping("/{wishlistId}") + suspend fun deleteWishlist( + @PathVariable + wishlistId: String, + @RequestHeader(AUTHORIZATION) + authorization: String, + ): DeleteWishlistResponse { + val memberId = tokenService.getMemberId(Authorization(authorization)) + val command = DeleteWishlistCommand(memberId, wishlistId) + wishlistService.delete(command) + return DeleteWishlistResponse() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/domain/Wishlist.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/domain/Wishlist.kt new file mode 100644 index 0000000..5f1e8ee --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/domain/Wishlist.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.wishlist.v1.domain + +import java.time.Instant +import java.util.* + +class Wishlist( + val id: UUID, + val memberId: UUID, + val ebookId: UUID, + val createdDate: Instant, +) diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/CreateWishlistCommand.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/CreateWishlistCommand.kt new file mode 100644 index 0000000..6643958 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/CreateWishlistCommand.kt @@ -0,0 +1,8 @@ +package com.devooks.backend.wishlist.v1.dto + +import java.util.* + +class CreateWishlistCommand( + val ebookId: UUID, + val requesterId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/CreateWishlistRequest.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/CreateWishlistRequest.kt new file mode 100644 index 0000000..dc4cfb5 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/CreateWishlistRequest.kt @@ -0,0 +1,16 @@ +package com.devooks.backend.wishlist.v1.dto + +import com.devooks.backend.wishlist.v1.error.validateEbookId +import java.util.* + +data class CreateWishlistRequest( + val ebookId: String?, +) { + + fun toCommand(requesterId: UUID): CreateWishlistCommand = + CreateWishlistCommand( + ebookId = ebookId.validateEbookId(), + requesterId = requesterId + ) + +} diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/CreateWishlistResponse.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/CreateWishlistResponse.kt new file mode 100644 index 0000000..7868a7f --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/CreateWishlistResponse.kt @@ -0,0 +1,23 @@ +package com.devooks.backend.wishlist.v1.dto + +import com.devooks.backend.wishlist.v1.domain.Wishlist +import java.time.Instant +import java.util.* + +data class CreateWishlistResponse( + val wishlistId: UUID, + val memberId: UUID, + val ebookId: UUID, + val createdDate: Instant +) { + + constructor( + wishlist: Wishlist, + ): this( + wishlistId = wishlist.id, + memberId = wishlist.memberId, + ebookId = wishlist.ebookId, + createdDate = wishlist.createdDate + ) + +} diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/DeleteWishlistCommand.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/DeleteWishlistCommand.kt new file mode 100644 index 0000000..94c99fe --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/DeleteWishlistCommand.kt @@ -0,0 +1,17 @@ +package com.devooks.backend.wishlist.v1.dto + +import com.devooks.backend.wishlist.v1.error.validateWishlistId +import java.util.* + +class DeleteWishlistCommand( + val memberId: UUID, + val wishlistId: UUID, +) { + constructor( + memberId: UUID, + wishlistId: String, + ): this( + memberId = memberId, + wishlistId = wishlistId.validateWishlistId() + ) +} diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/DeleteWishlistResponse.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/DeleteWishlistResponse.kt new file mode 100644 index 0000000..915501c --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/DeleteWishlistResponse.kt @@ -0,0 +1,5 @@ +package com.devooks.backend.wishlist.v1.dto + +data class DeleteWishlistResponse( + val message: String = "찜 삭제를 완료했습니다." +) diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/GetWishlistCommand.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/GetWishlistCommand.kt new file mode 100644 index 0000000..019c4cc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/GetWishlistCommand.kt @@ -0,0 +1,29 @@ +package com.devooks.backend.wishlist.v1.dto + +import com.devooks.backend.common.dto.Paging +import com.devooks.backend.wishlist.v1.error.validateCategoryIds +import java.util.* +import org.springframework.data.domain.Pageable + +class GetWishlistCommand( + val memberId: UUID, + val categoryIds: List?, + private val pageable: Pageable, +) { + constructor( + memberId: UUID, + categoryIds: List, + page: String, + count: String, + ) : this( + memberId = memberId, + categoryIds = categoryIds.takeIf { it.isNotEmpty() }?.validateCategoryIds(), + pageable = Paging(page, count).value + ) + + val offset: Int + get() = pageable.offset.toInt() + + val limit: Int + get() = pageable.pageSize +} diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/GetWishlistResponse.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/GetWishlistResponse.kt new file mode 100644 index 0000000..fb4011a --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/GetWishlistResponse.kt @@ -0,0 +1,12 @@ +package com.devooks.backend.wishlist.v1.dto + +import com.devooks.backend.wishlist.v1.domain.Wishlist + +data class GetWishlistResponse( + val wishlist: List, +) { + companion object { + fun List.toResponse() = + GetWishlistResponse(map { WishlistDto(it.id, it.memberId, it.ebookId) }) + } +} diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/WishlistDto.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/WishlistDto.kt new file mode 100644 index 0000000..72db519 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/dto/WishlistDto.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.wishlist.v1.dto + +import java.util.* + +data class WishlistDto( + val id: UUID, + val memberId: UUID, + val ebookId: UUID, +) diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/entity/WishlistEntity.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/entity/WishlistEntity.kt new file mode 100644 index 0000000..98e1ebc --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/entity/WishlistEntity.kt @@ -0,0 +1,32 @@ +package com.devooks.backend.wishlist.v1.entity + +import com.devooks.backend.wishlist.v1.domain.Wishlist +import java.time.Instant +import java.util.* +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table + +@Table(value = "wishlist") +data class WishlistEntity( + @Id + @Column(value = "wishlist_id") + @get:JvmName("wishlistId") + val id: UUID? = null, + val memberId: UUID, + val ebookId: UUID, + val createdDate: Instant = Instant.now(), +) : Persistable { + override fun getId(): UUID? = id + + override fun isNew(): Boolean = id == null + + fun toDomain() = + Wishlist( + id = this.id!!, + memberId = this.memberId, + ebookId = this.ebookId, + createdDate = this.createdDate + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/error/WishlistError.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/error/WishlistError.kt new file mode 100644 index 0000000..621c668 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/error/WishlistError.kt @@ -0,0 +1,29 @@ +package com.devooks.backend.wishlist.v1.error + +import com.devooks.backend.common.exception.GeneralException +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.CONFLICT +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.NOT_FOUND + +enum class WishlistError(val exception: GeneralException) { + // 400 + REQUIRED_EBOOK_ID(GeneralException("WISHLIST-400-1", BAD_REQUEST, "전자책 식별자가 반드시 필요합니다.")), + INVALID_EBOOK_ID(GeneralException("WISHLIST-400-2", BAD_REQUEST, "잘못된 형식의 전자책 식별자 입니다.")), + INVALID_CATEGORY_ID(GeneralException("WISHLIST-400-3", BAD_REQUEST, "잘못된 형식의 카테고리 식별자 입니다.")), + INVALID_WISHLIST_ID(GeneralException("WISHLIST-400-4", BAD_REQUEST, "잘못된 형식의 찜 식별자 입니다.")), + + // 403 + FORBIDDEN_DELETE_WISHLIST(GeneralException("WISHLIST-403-1", FORBIDDEN, "자신의 찜만 삭제할 수 있습니다.")), + + // 404 + NOT_FOUND_WISHLIST(GeneralException("WISHLIST-404-1", NOT_FOUND, "존재하지 않는 찜입니다.")), + + // 409 + DUPLICATE_WISHLIST(GeneralException("WISHLIST-409-1", CONFLICT, "이미 존재하는 찜입니다.")) + ; + + override fun toString(): String { + return "WishlistError(exception=$exception)" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/error/WishlistValidation.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/error/WishlistValidation.kt new file mode 100644 index 0000000..2bae4b5 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/error/WishlistValidation.kt @@ -0,0 +1,15 @@ +package com.devooks.backend.wishlist.v1.error + +import com.devooks.backend.common.error.validateUUID +import com.devooks.backend.common.error.validateNotBlank +import java.util.* + +fun String?.validateEbookId(): UUID = + validateNotBlank(WishlistError.REQUIRED_EBOOK_ID.exception) + .validateUUID(WishlistError.INVALID_EBOOK_ID.exception) + +fun List.validateCategoryIds(): List = + map { it.validateUUID(WishlistError.INVALID_CATEGORY_ID.exception) } + +fun String.validateWishlistId(): UUID = + validateUUID(WishlistError.INVALID_WISHLIST_ID.exception) \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/repository/WishlistQueryRepository.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/repository/WishlistQueryRepository.kt new file mode 100644 index 0000000..39fd096 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/repository/WishlistQueryRepository.kt @@ -0,0 +1,57 @@ +package com.devooks.backend.wishlist.v1.repository + +import com.devooks.backend.wishlist.v1.domain.Wishlist +import com.devooks.backend.wishlist.v1.dto.GetWishlistCommand +import java.time.Instant +import java.util.* +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.asFlow +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.stereotype.Repository + +@Repository +class WishlistQueryRepository( + private val databaseClient: DatabaseClient, +) { + + suspend fun findBy(command: GetWishlistCommand): List { + val bindings = mutableMapOf() + val query = """ + SELECT w.* + FROM wishlist w, + ebook e, + related_category r + WHERE w.ebook_id = e.ebook_id + AND e.ebook_id = r.ebook_id + AND w.member_id = ${ + command.memberId.let { + bindings["memberId"] = command.memberId + ":memberId" + } + } + ${ + command.categoryIds?.let { + bindings["categoryIds"] = command.categoryIds + "AND r.category_id IN (:categoryIds)" + } ?: "" + } + OFFSET ${command.offset} LIMIT ${command.limit}; + """.trimIndent() + + return databaseClient + .sql(query) + .bindValues(bindings) + .map { row -> + Wishlist( + id = row.get("wishlist_id", UUID::class.java)!!, + memberId = row.get("member_id", UUID::class.java)!!, + ebookId = row.get("ebook_id", UUID::class.java)!!, + createdDate = row.get("created_date", Instant::class.java)!! + ) + } + .all() + .asFlow() + .toList() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/repository/WishlistRepository.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/repository/WishlistRepository.kt new file mode 100644 index 0000000..4613ff6 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/repository/WishlistRepository.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.wishlist.v1.repository + +import com.devooks.backend.wishlist.v1.entity.WishlistEntity +import java.util.* +import org.springframework.data.repository.kotlin.CoroutineCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface WishlistRepository : CoroutineCrudRepository { + suspend fun findByMemberIdAndEbookId(memberId: UUID, ebookId: UUID): WishlistEntity? +} diff --git a/src/main/kotlin/com/devooks/backend/wishlist/v1/service/WishlistService.kt b/src/main/kotlin/com/devooks/backend/wishlist/v1/service/WishlistService.kt new file mode 100644 index 0000000..a8ce442 --- /dev/null +++ b/src/main/kotlin/com/devooks/backend/wishlist/v1/service/WishlistService.kt @@ -0,0 +1,37 @@ +package com.devooks.backend.wishlist.v1.service + +import com.devooks.backend.ebook.v1.domain.Ebook +import com.devooks.backend.wishlist.v1.domain.Wishlist +import com.devooks.backend.wishlist.v1.dto.CreateWishlistCommand +import com.devooks.backend.wishlist.v1.dto.DeleteWishlistCommand +import com.devooks.backend.wishlist.v1.dto.GetWishlistCommand +import com.devooks.backend.wishlist.v1.entity.WishlistEntity +import com.devooks.backend.wishlist.v1.error.WishlistError +import com.devooks.backend.wishlist.v1.repository.WishlistQueryRepository +import com.devooks.backend.wishlist.v1.repository.WishlistRepository +import org.springframework.stereotype.Service + +@Service +class WishlistService( + private val wishlistRepository: WishlistRepository, + private val wishlistQueryRepository: WishlistQueryRepository, +) { + suspend fun create(command: CreateWishlistCommand, ebook: Ebook): Wishlist { + wishlistRepository + .findByMemberIdAndEbookId(command.requesterId, ebook.id) + ?.also { throw WishlistError.DUPLICATE_WISHLIST.exception } + val wishlistEntity = WishlistEntity(memberId = command.requesterId, ebookId = ebook.id) + return wishlistRepository.save(wishlistEntity).toDomain() + } + + suspend fun get(command: GetWishlistCommand): List = + wishlistQueryRepository.findBy(command) + + suspend fun delete(command: DeleteWishlistCommand) { + wishlistRepository + .findById(command.wishlistId) + ?.also { if (it.memberId != command.memberId) { throw WishlistError.FORBIDDEN_DELETE_WISHLIST.exception } } + ?.also { wishlistRepository.delete(it) } + ?: throw WishlistError.NOT_FOUND_WISHLIST.exception + } +} \ No newline at end of file diff --git a/src/main/resources/sample.application.yml b/src/main/resources/sample.application.yml new file mode 100644 index 0000000..61c1f4e --- /dev/null +++ b/src/main/resources/sample.application.yml @@ -0,0 +1,51 @@ +server: + port: 8081 + +spring: + r2dbc: + url: ${DATABASE_URL:r2dbc:postgresql://localhost:35432/devooksdb} + driver: postgresql + protocol: r2dbc + host: localhost + port: 35432 + database: devooksdb + username: ${DATABASE_USERNAME:devooks} + password: ${DATABASE_PASSWORD:devooks} + + codec: + max-in-memory-size: 500MB + +jwt: + secretKey: + accessTokenExpirationHour: 1 + refreshTokenExpirationHour: 720 + +naver: + oauthHost: https://nid.naver.com + profileHost: https://openapi.naver.com + tokenUrl: /oauth2.0/token + profileUrl: /v1/nid/me + clientId: + clientSecret: + state: + +kakao: + oauthHost: https://kauth.kakao.com + profileHost: https://kapi.kakao.com + tokenUrl: /oauth/token + profileUrl: /v2/user/me + clientId: + redirectUri: + +google: + oauthHost: https://oauth2.googleapis.com + profileHost: https://www.googleapis.com + tokenUrl: /token + profileUrl: /userinfo/v2/me + clientId: + clientSecret: + redirectUri: + +springdoc: + swagger-ui: + path: api-docs diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..206411a --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,216 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS member +( + member_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + nickname VARCHAR UNIQUE NOT NULL, + profile_image_path VARCHAR UNIQUE, + authority VARCHAR NOT NULL, + withdrawal_date TIMESTAMP, + until_suspension_date TIMESTAMP, + registered_date TIMESTAMP NOT NULL, + modified_date TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS member_info +( + member_info_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + blog_link VARCHAR NOT NULL, + instagram_link VARCHAR NOT NULL, + youtube_link VARCHAR NOT NULL, + real_name VARCHAR NOT NULL, + bank VARCHAR NOT NULL, + account_number VARCHAR NOT NULL, + introduction TEXT NOT NULL, + phone_number VARCHAR NOT NULL, + member_id uuid NOT NULL UNIQUE, + CONSTRAINT fk_member_id FOREIGN KEY (member_id) REFERENCES member_info (member_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS oauth_info +( + oauth_id VARCHAR PRIMARY KEY, + oauth_type VARCHAR NOT NULL, + member_id uuid UNIQUE NOT NULL, + registered_date TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT fk_member_id FOREIGN KEY (member_id) REFERENCES member (member_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS refresh_token +( + refresh_token_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + member_id uuid UNIQUE NOT NULL, + token VARCHAR UNIQUE NOT NULL, + registered_date TIMESTAMP NOT NULL, + modified_date TIMESTAMP NOT NULL, + CONSTRAINT fk_member_id FOREIGN KEY (member_id) REFERENCES member (member_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS category +( + category_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR UNIQUE NOT NULL, + registered_date TIMESTAMP NOT NULL, + modified_date TIMESTAMP NOT NULL, + deleted_date TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS favorite_category +( + favorite_category_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + favorite_member_id uuid NOT NULL, + category_id uuid NOT NULL, + CONSTRAINT fk_member_id FOREIGN KEY (favorite_member_id) + REFERENCES member (member_id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_category_id FOREIGN KEY (category_id) + REFERENCES category (category_id) +); + +CREATE TABLE IF NOT EXISTS pdf +( + pdf_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + file_path VARCHAR UNIQUE NOT NULL, + page_count INT NOT NULL, + created_date TIMESTAMP NOT NULL, + upload_member_id uuid NOT NULL, + CONSTRAINT fk_member_id FOREIGN KEY (upload_member_id) REFERENCES member (member_id) +); + +CREATE TABLE IF NOT EXISTS preview_image +( + preview_image_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + image_path VARCHAR UNIQUE NOT NULL, + preview_order INT NOT NULL, + pdf_id uuid NOT NULL, + CONSTRAINT fk_pdf_id FOREIGN KEY (pdf_id) REFERENCES pdf (pdf_id) +); + +CREATE TABLE IF NOT EXISTS ebook +( + ebook_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + selling_member_id uuid NOT NULL, + pdf_id uuid NOT NULL UNIQUE, + main_image_id uuid NOT NULL UNIQUE, + title VARCHAR NOT NULL, + price INT NOT NULL, + table_of_contents TEXT NOT NULL, + introduction TEXT NOT NULL, + created_date TIMESTAMP NOT NULL, + modified_date TIMESTAMP NOT NULL, + deleted_date TIMESTAMP, + CONSTRAINT fk_member_id FOREIGN KEY (selling_member_id) REFERENCES member (member_id), + CONSTRAINT fk_pdf_id FOREIGN KEY (pdf_id) REFERENCES pdf (pdf_id) +); + +CREATE TABLE IF NOT EXISTS ebook_image +( + ebook_image_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + image_path VARCHAR UNIQUE NOT NULL, + image_order INT NOT NULL, + upload_member_id uuid NOT NULL, + ebook_id uuid, + CONSTRAINT fk_ebook_id FOREIGN KEY (ebook_id) REFERENCES ebook (ebook_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS related_category +( + related_category_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + ebook_id uuid NOT NULL, + category_id uuid NOT NULL, + CONSTRAINT fk_ebook_id FOREIGN KEY (ebook_id) REFERENCES ebook (ebook_id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_category_id FOREIGN KEY (category_id) REFERENCES category (category_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS wishlist +( + wishlist_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + member_id uuid NOT NULL, + ebook_id uuid NOT NULL, + created_date TIMESTAMP NOT NULL, + CONSTRAINT fk_member_id FOREIGN KEY (member_id) REFERENCES member (member_id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_ebook_id FOREIGN KEY (ebook_id) REFERENCES ebook (ebook_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS transaction +( + transaction_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + ebook_id uuid NOT NULL, + price INT NOT NULL, + payment_method VARCHAR NOT NULL, + buyer_member_id uuid NOT NULL, + transaction_date TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS review +( + review_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + rating INT NOT NULL, + content VARCHAR NOT NULL, + ebook_id uuid NOT NULL, + writer_member_id uuid NOT NULL, + written_date TIMESTAMP NOT NULL, + modified_date TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS review_comment +( + review_comment_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + content VARCHAR NOT NULL, + review_id uuid NOT NULL, + writer_member_id uuid NOT NULL, + written_date TIMESTAMP NOT NULL, + modified_date TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS ebook_inquiry +( + ebook_inquiry_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + content VARCHAR NOT NULL, + ebook_id uuid NOT NULL, + writer_member_id uuid NOT NULL, + written_date TIMESTAMP NOT NULL, + modified_date TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS ebook_inquiry_comment +( + ebook_inquiry_comment_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + content VARCHAR NOT NULL, + inquiry_id uuid NOT NULL, + writer_member_id uuid NOT NULL, + written_date TIMESTAMP NOT NULL, + modified_date TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS service_inquiry +( + service_inquiry_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + title VARCHAR NOT NULL, + content TEXT NOT NULL, + created_date TIMESTAMP NOT NULL, + modified_date TIMESTAMP NOT NULL, + inquiry_processing_status VARCHAR NOT NULL, + writer_member_id uuid NOT NULL, + CONSTRAINT fk_member_id FOREIGN KEY (writer_member_id) REFERENCES member (member_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS service_inquiry_image +( + service_inquiry_image_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + image_path VARCHAR UNIQUE NOT NULL, + image_order INT NOT NULL, + upload_member_id uuid NOT NULL, + service_inquiry_id uuid, + CONSTRAINT fk_service_inquiry_id FOREIGN KEY (service_inquiry_id) REFERENCES service_inquiry (service_inquiry_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS notification +( + notification_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR NOT NULL, + content VARCHAR NOT NULL, + note jsonb NOT NULL, + receiver_id uuid NOT NULL, + notified_date TIMESTAMP NOT NULL, + checked BOOLEAN NOT NULL +); diff --git a/src/test/kotlin/com/devooks/backend/auth/v1/controller/AuthControllerTest.kt b/src/test/kotlin/com/devooks/backend/auth/v1/controller/AuthControllerTest.kt new file mode 100644 index 0000000..4c36fae --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/auth/v1/controller/AuthControllerTest.kt @@ -0,0 +1,402 @@ +package com.devooks.backend.auth.v1.controller + +import com.devooks.backend.auth.v1.client.naver.NaverOauthClient +import com.devooks.backend.auth.v1.client.naver.NaverProfileClient +import com.devooks.backend.auth.v1.client.naver.dto.GetNaverProfileResponse +import com.devooks.backend.auth.v1.client.naver.dto.GetNaverTokenResponse +import com.devooks.backend.auth.v1.config.oauth.NaverOauthProperties +import com.devooks.backend.auth.v1.domain.OauthGrantType +import com.devooks.backend.auth.v1.domain.OauthType +import com.devooks.backend.auth.v1.dto.LoginRequest +import com.devooks.backend.auth.v1.dto.LoginResponse +import com.devooks.backend.auth.v1.dto.LogoutRequest +import com.devooks.backend.auth.v1.dto.ReissueRequest +import com.devooks.backend.auth.v1.error.AuthError +import com.devooks.backend.auth.v1.repository.OauthInfoRepository +import com.devooks.backend.auth.v1.repository.RefreshTokenRepository +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.fixture.ErrorResponse +import com.devooks.backend.fixture.ErrorResponse.Companion.postForBadRequest +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.dto.SignUpRequest +import com.devooks.backend.member.v1.dto.SignUpResponse +import com.devooks.backend.member.v1.dto.WithdrawMemberRequest +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.error.MemberError +import com.devooks.backend.member.v1.repository.MemberRepository +import java.time.Instant +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.BDDMockito.given +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody + +@IntegrationTest +internal class AuthControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val refreshTokenRepository: RefreshTokenRepository, + private val memberRepository: MemberRepository, + private val naverOauthProperties: NaverOauthProperties, + private val oauthInfoRepository: OauthInfoRepository, + private val tokenService: TokenService, +) { + + lateinit var signUpRequest: SignUpRequest + lateinit var member: MemberEntity + + @MockBean + lateinit var naverOauthClient: NaverOauthClient + + @MockBean + lateinit var naverProfileClient: NaverProfileClient + + @BeforeEach + fun setUp(): Unit = runBlocking { + signUpRequest = SignUpRequest("oauthId", OauthType.NAVER.name, "nickname", listOf()) + val responseMember = webTestClient + .post() + .uri("/api/v1/members/signup") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(signUpRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .member + member = memberRepository.findById(responseMember.id)!! + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + refreshTokenRepository.deleteAll() + oauthInfoRepository.deleteAll() + memberRepository.deleteAll() + } + + @Test + fun `로그인 할 수 있다`(): Unit = runBlocking { + // given + // when + val response = postLogin() + + // then + assertThat(response.member.id).isEqualTo(member.id) + } + + @Test + fun `잘못된 인증 코드로 로그인을 실패할 경우 예외가 발생한다`(): Unit = runBlocking { + val request = LoginRequest("code", OauthType.NAVER.name) + val getNaverTokenResponse = + GetNaverTokenResponse(null, null, null, null, "error", "erorDescription") + given( + naverOauthClient.getToken( + OauthGrantType.AUTHORIZATION_CODE.value, + naverOauthProperties.clientId, + naverOauthProperties.clientSecret, + request.authorizationCode!!, + state = naverOauthProperties.state + ) + ).willReturn(getNaverTokenResponse) + + val error = webTestClient + .post() + .uri("/api/v1/auth/login") + .contentType(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isUnauthorized + .expectBody() + .returnResult() + .responseBody!! + + assertThat(error.code).isEqualTo(AuthError.FAILED_NAVER_OAUTH_LOGIN.exception.code) + } + + @Test + fun `회원이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val request = LoginRequest("code", OauthType.NAVER.name) + val getNaverTokenResponse = + GetNaverTokenResponse("accessToken", "refreshToken", "tokenType", "expiresIn", null, null) + given( + naverOauthClient.getToken( + OauthGrantType.AUTHORIZATION_CODE.value, + naverOauthProperties.clientId, + naverOauthProperties.clientSecret, + request.authorizationCode!!, + state = naverOauthProperties.state + ) + ).willReturn(getNaverTokenResponse) + + val oauthId = "wrong" + given(naverProfileClient.getOauthId(getNaverTokenResponse.token!!)) + .willReturn( + GetNaverProfileResponse( + "resultCode", + "message", + GetNaverProfileResponse.Profile(oauthId, oauthId) + ) + ) + + val responseBody = webTestClient + .post() + .uri("/api/v1/auth/login") + .contentType(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isNotFound + .expectBody>() + .returnResult() + .responseBody!! + + val message = responseBody["message"] as Map + assertThat(message["oauthId"]).isEqualTo(oauthId) + } + + @Test + fun `정지 당한 회원일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = LoginRequest("code", OauthType.NAVER.name) + val getNaverTokenResponse = + GetNaverTokenResponse("accessToken", "refreshToken", "tokenType", "expiresIn", null, null) + memberRepository.save(member.copy(untilSuspensionDate = Instant.now().plusSeconds(60L))) + given( + naverOauthClient.getToken( + OauthGrantType.AUTHORIZATION_CODE.value, + naverOauthProperties.clientId, + naverOauthProperties.clientSecret, + request.authorizationCode!!, + state = naverOauthProperties.state + ) + ).willReturn(getNaverTokenResponse) + + given(naverProfileClient.getOauthId(getNaverTokenResponse.token!!)) + .willReturn( + GetNaverProfileResponse( + "resultCode", + "message", + GetNaverProfileResponse.Profile(signUpRequest.oauthId, "email") + ) + ) + + val code = webTestClient + .post() + .uri("/api/v1/auth/login") + .contentType(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isForbidden + .expectBody() + .returnResult() + .responseBody!! + .code + + assertThat(code).isEqualTo(MemberError.SUSPENDED_MEMBER.exception.code) + } + + @Test + fun `탈퇴한 회원일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = LoginRequest("code", OauthType.NAVER.name) + val getNaverTokenResponse = + GetNaverTokenResponse("accessToken", "refreshToken", "tokenType", "expiresIn", null, null) + given( + naverOauthClient.getToken( + OauthGrantType.AUTHORIZATION_CODE.value, + naverOauthProperties.clientId, + naverOauthProperties.clientSecret, + request.authorizationCode!!, + state = naverOauthProperties.state + ) + ).willReturn(getNaverTokenResponse) + + given(naverProfileClient.getOauthId(getNaverTokenResponse.token!!)) + .willReturn( + GetNaverProfileResponse( + "resultCode", + "message", + GetNaverProfileResponse.Profile(signUpRequest.oauthId, "email") + ) + ) + + val accessToken = tokenService.createTokenGroup(member.toDomain()).accessToken + + webTestClient + .patch() + .uri("/api/v1/members/withdrawal") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(WithdrawMemberRequest(withdrawalReason = "reason")) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + + val code = webTestClient + .post() + .uri("/api/v1/auth/login") + .contentType(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isForbidden + .expectBody() + .returnResult() + .responseBody!! + .code + + assertThat(code).isEqualTo(MemberError.WITHDREW_MEMBER.exception.code) + } + + @Test + fun `로그아웃 할 수 있다`(): Unit = runBlocking { + // given + val loginResponse = postLogin() + val logoutRequest = LogoutRequest(loginResponse.tokenGroup.refreshToken) + + // when + // then + webTestClient + .post() + .uri("/api/v1/auth/logout") + .contentType(APPLICATION_JSON) + .bodyValue(logoutRequest) + .exchange() + .expectStatus().isOk + } + + @Test + fun `토큰을 재발급 받을 수 있다`(): Unit = runBlocking { + // given + val loginResponse = postLogin() + val reissueRequest = ReissueRequest(loginResponse.tokenGroup.refreshToken) + + // when + webTestClient + .post() + .uri("/api/v1/auth/reissue") + .contentType(APPLICATION_JSON) + .bodyValue(reissueRequest) + .exchange() + .expectStatus().isOk + } + + @Test + fun `authorizationCode가 존재하지 않을 경우 로그인시 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "oauthType" : "NAVER" + } + """.trimIndent() + + val response = webTestClient.postForBadRequest("/api/v1/auth/login", request) + + response.isEqualTo(AuthError.REQUIRED_AUTHORIZATION_CODE.exception) + } + + @Test + fun `authorizationCode가 비어있을 경우 로그인시 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "authorizationCode" : "", + "oauthType" : "NAVER" + } + """.trimIndent() + + val response = webTestClient.postForBadRequest("/api/v1/auth/login", request) + + response.isEqualTo(AuthError.REQUIRED_AUTHORIZATION_CODE.exception) + } + + @Test + fun `oauthType이 존재하지 않을 경우 로그인시 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "authorizationCode": "code" + } + """.trimIndent() + + val response = webTestClient.postForBadRequest("/api/v1/auth/login", request) + + response.isEqualTo(AuthError.INVALID_OAUTH_TYPE.exception) + } + + @Test + fun `oauthType이 값이 NAVER, GOOGLE, KAKAO가 아닐 경우 로그인시 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "authorizationCode": "code", + "oauthType": "NAVERR" + } + """.trimIndent() + + val response = webTestClient.postForBadRequest("/api/v1/auth/login", request) + + response.isEqualTo(AuthError.INVALID_OAUTH_TYPE.exception) + } + + @Test + fun `refreshToken이 존재하지 않을 경우 로그아웃시 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + } + """.trimIndent() + + val response = webTestClient.postForBadRequest("/api/v1/auth/logout", request) + + response.isEqualTo(AuthError.REQUIRED_TOKEN.exception) + } + + @Test + fun `refreshToken이 비어있을 경우 로그아웃시 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "refreshToken" : "" + } + """.trimIndent() + + val response = webTestClient.postForBadRequest("/api/v1/auth/logout", request) + + response.isEqualTo(AuthError.REQUIRED_TOKEN.exception) + } + + private fun postLogin(): LoginResponse { + val request = LoginRequest("code", OauthType.NAVER.name) + val getNaverTokenResponse = + GetNaverTokenResponse("accessToken", "refreshToken", "tokenType", "expiresIn", null, null) + given( + naverOauthClient.getToken( + OauthGrantType.AUTHORIZATION_CODE.value, + naverOauthProperties.clientId, + naverOauthProperties.clientSecret, + request.authorizationCode!!, + state = naverOauthProperties.state + ) + ).willReturn(getNaverTokenResponse) + given(naverProfileClient.getOauthId(getNaverTokenResponse.token!!)) + .willReturn( + GetNaverProfileResponse( + "resultCode", + "message", + GetNaverProfileResponse.Profile(signUpRequest.oauthId, "email") + ) + ) + + val response = webTestClient + .post() + .uri("/api/v1/auth/login") + .contentType(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return response + } +} diff --git a/src/test/kotlin/com/devooks/backend/category/v1/controller/CategoryControllerTest.kt b/src/test/kotlin/com/devooks/backend/category/v1/controller/CategoryControllerTest.kt new file mode 100644 index 0000000..2151e07 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/category/v1/controller/CategoryControllerTest.kt @@ -0,0 +1,85 @@ +package com.devooks.backend.category.v1.controller + +import com.devooks.backend.auth.v1.domain.OauthType +import com.devooks.backend.auth.v1.repository.OauthInfoRepository +import com.devooks.backend.auth.v1.repository.RefreshTokenRepository +import com.devooks.backend.category.v1.dto.GetCategoriesResponse +import com.devooks.backend.category.v1.repository.CategoryRepository +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.member.v1.dto.SignUpRequest +import com.devooks.backend.member.v1.dto.SignUpResponse +import com.devooks.backend.member.v1.repository.FavoriteCategoryRepository +import com.devooks.backend.member.v1.repository.MemberInfoRepository +import com.devooks.backend.member.v1.repository.MemberRepository +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody + +@IntegrationTest +internal class CategoryControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val memberRepository: MemberRepository, + private val memberInfoRepository: MemberInfoRepository, + private val oauthInfoRepository: OauthInfoRepository, + private val categoryRepository: CategoryRepository, + private val favoriteCategoryRepository: FavoriteCategoryRepository, + private val refreshTokenRepository: RefreshTokenRepository, +) { + + private val expectedCategory = "category" + + @BeforeEach + fun setup(): Unit = runBlocking { + val request = SignUpRequest( + oauthId = "oauthId", + oauthType = OauthType.NAVER.name, + nickname = "nickname", + favoriteCategories = listOf(expectedCategory) + ) + + webTestClient + .post() + .uri("/api/v1/members/signup") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + refreshTokenRepository.deleteAll() + favoriteCategoryRepository.deleteAll() + memberRepository.deleteAll() + oauthInfoRepository.deleteAll() + categoryRepository.deleteAll() + memberInfoRepository.deleteAll() + } + + @Test + fun `카테고리 목록을 조회할 수 있다`(): Unit = runBlocking { + val categories = webTestClient + .get() + .uri("/api/v1/categories?page=1&count=10") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .categories + + assertThat(categories[0].name).isEqualTo(expectedCategory) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/category/v1/repository/CategoryRepositoryTest.kt b/src/test/kotlin/com/devooks/backend/category/v1/repository/CategoryRepositoryTest.kt new file mode 100644 index 0000000..76ef104 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/category/v1/repository/CategoryRepositoryTest.kt @@ -0,0 +1,53 @@ +package com.devooks.backend.category.v1.repository + +import com.devooks.backend.category.v1.entity.CategoryEntity +import com.devooks.backend.config.IntegrationTest +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.domain.Pageable + +@IntegrationTest +internal class CategoryRepositoryTest @Autowired constructor( + private val categoryRepository: CategoryRepository, +) { + + @AfterEach + fun tearDown(): Unit = runBlocking { + categoryRepository.deleteAll() + } + + @Test + fun `카테고리를 조회할 수 있다`(): Unit = runBlocking { + // given + val entity = CategoryEntity(name = "category") + categoryRepository.save(entity) + + // when + val categories = categoryRepository + .findAllByNameLikeIgnoreCase("%c%", Pageable.ofSize(1).withPage(0)) + .toList() + + // then + assertThat(categories.first().name).isEqualTo(entity.name) + } + + @Test + fun `카테고리를 전체 조회할 수 있다`(): Unit = runBlocking { + // given + val entity = CategoryEntity(name = "category") + categoryRepository.save(entity) + + // when + val categories = categoryRepository + .findAllByNameLikeIgnoreCase("%c%") + .toList() + + // then + assertThat(categories.first().name).isEqualTo(entity.name) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/category/v1/service/CategoryServiceTest.kt b/src/test/kotlin/com/devooks/backend/category/v1/service/CategoryServiceTest.kt new file mode 100644 index 0000000..9236532 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/category/v1/service/CategoryServiceTest.kt @@ -0,0 +1,50 @@ +package com.devooks.backend.category.v1.service + +import com.devooks.backend.category.v1.entity.CategoryEntity +import com.devooks.backend.category.v1.repository.CategoryRepository +import com.devooks.backend.config.IntegrationTest +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired + +@IntegrationTest +internal class CategoryServiceTest @Autowired constructor( + private val categoryService: CategoryService, + private val categoryRepository: CategoryRepository +) { + + @AfterEach + fun tearDown(): Unit = runBlocking { + categoryRepository.deleteAll() + } + + @Test + fun `카테고리를 저장할 수 있다`(): Unit = runBlocking { + // given + val categoryNames = listOf("category") + + // when + val categories = categoryService.save(categoryNames) + val entity = categoryRepository.findByNameIsIgnoreCase("category") + + // then + assertThat(categories[0].name).isEqualTo(entity!!.name) + } + + @Test + fun `카테고리가 이미 존재할 경우 저장하지 않는다`(): Unit = runBlocking { + // given + val categoryNames = listOf("category") + val categoryEntity = categoryRepository.save(CategoryEntity(name = categoryNames[0])) + + // when + val categories = categoryService.save(categoryNames) + + // then + assertThat(categoryRepository.count()).isOne() + assertThat(categories[0].id).isEqualTo(categoryEntity.id) + assertThat(categories[0].name).isEqualTo(categoryEntity.name) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/common/CustomFilePart.kt b/src/test/kotlin/com/devooks/backend/common/CustomFilePart.kt new file mode 100644 index 0000000..9767969 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/common/CustomFilePart.kt @@ -0,0 +1,56 @@ +package com.devooks.backend.common + +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.coroutines.runBlocking +import org.springframework.core.io.buffer.DataBuffer +import org.springframework.core.io.buffer.DataBufferUtils +import org.springframework.core.io.buffer.DefaultDataBufferFactory +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.codec.multipart.FilePart +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +class CustomFilePart(private val file: File) : FilePart { + + override fun name(): String { + return file.name + } + + override fun filename(): String { + return file.name + } + + override fun headers(): HttpHeaders { + val headers = HttpHeaders() + headers.contentDisposition = org.springframework.http.ContentDisposition.builder("form-data") + .name(name()) + .filename(filename()) + .build() + headers.contentType = MediaType.MULTIPART_FORM_DATA + headers.contentLength = file.length() + return headers + } + + override fun content(): Flux { + return DataBufferUtils.read(file.toPath(), DefaultDataBufferFactory.sharedInstance, 4096) + } + + override fun transferTo(dest: File): Mono { + return Mono.fromRunnable { + runBlocking { + Files.copy(file.toPath(), dest.toPath()) + } + } + } + + override fun transferTo(dest: Path): Mono { + return Mono.fromRunnable { + runBlocking { + Files.copy(file.toPath(), dest) + } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/common/ThrowAsserts.kt b/src/test/kotlin/com/devooks/backend/common/ThrowAsserts.kt new file mode 100644 index 0000000..87ee994 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/common/ThrowAsserts.kt @@ -0,0 +1,11 @@ +package com.devooks.backend.common + +import com.devooks.backend.common.exception.GeneralException +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.assertThrows + +inline fun assertThrows(exception: GeneralException, block: () -> Unit) { + val (code, _, _) = assertThrows(block) + + assertThat(code).isEqualTo(exception.code) +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/common/dto/PagingTest.kt b/src/test/kotlin/com/devooks/backend/common/dto/PagingTest.kt new file mode 100644 index 0000000..69bdaea --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/common/dto/PagingTest.kt @@ -0,0 +1,29 @@ +package com.devooks.backend.common.dto + +import com.devooks.backend.common.assertThrows +import com.devooks.backend.common.error.CommonError +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class PagingTest { + + @Test + fun `Paging으로부터 offset과 limit을 가져올 수 있다`() { + val list = listOf(1, 2, 3, 4) + val paging1 = Paging(page = "1", count = "2") + val paging2 = Paging(page = "2", count = "2") + + assertThat(list.subList(paging1.offset, paging1.limit)).containsAll(listOf(1, 2)) + assertThat(list.subList(paging2.offset, paging2.limit)).containsAll(listOf(3, 4)) + } + + @Test + fun `페이지가 0 이하일 경우 예외가 발생한다`() { + assertThrows(CommonError.INVALID_PAGE.exception) { Paging(page = "0", count = "2") } + } + + @Test + fun `개수가 0 이하이며 1000초과일 경우 예외가 발생한다`() { + assertThrows(CommonError.INVALID_COUNT.exception) { Paging(page = "1", count = "1001") } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/config/IntegrationTest.kt b/src/test/kotlin/com/devooks/backend/config/IntegrationTest.kt new file mode 100644 index 0000000..d8f08fa --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/config/IntegrationTest.kt @@ -0,0 +1,9 @@ +package com.devooks.backend.config + +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ContextConfiguration + +@Target(AnnotationTarget.CLASS) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = [PostgresqlInitializer::class]) +annotation class IntegrationTest diff --git a/src/test/kotlin/com/devooks/backend/config/PostgresqlInitializer.kt b/src/test/kotlin/com/devooks/backend/config/PostgresqlInitializer.kt new file mode 100644 index 0000000..c1162a8 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/config/PostgresqlInitializer.kt @@ -0,0 +1,34 @@ +package com.devooks.backend.config + +import org.springframework.boot.test.util.TestPropertyValues +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.ConfigurableApplicationContext + +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName + +class PostgresqlInitializer : ApplicationContextInitializer { + + private val postgresqlContainer = PostgreSQLContainer( + DockerImageName.parse("postgres:alpine") + ).withDatabaseName("devooksdb").withInitScript("schema.sql") + + override fun initialize(applicationContext: ConfigurableApplicationContext) { + postgresqlContainer.start() + + val url = "r2dbc:postgresql://" + + "${postgresqlContainer.host}:${postgresqlContainer.firstMappedPort}/${postgresqlContainer.databaseName}" + TestPropertyValues.of( + mapOf( + "spring.r2dbc.url" to url, + "spring.r2dbc.driver" to "postgresql", + "spring.r2dbc.protocol" to "r2dbc", + "spring.r2dbc.host" to postgresqlContainer.host, + "spring.r2dbc.port" to postgresqlContainer.firstMappedPort.toString(), + "spring.r2dbc.database" to postgresqlContainer.databaseName, + "spring.r2dbc.username" to postgresqlContainer.username, + "spring.r2dbc.password" to postgresqlContainer.password, + ) + ).applyTo(applicationContext) + } +} diff --git a/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookControllerTest.kt b/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookControllerTest.kt new file mode 100644 index 0000000..2050323 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookControllerTest.kt @@ -0,0 +1,1076 @@ +package com.devooks.backend.ebook.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.domain.AccessToken +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.category.v1.repository.CategoryRepository +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto.Companion.toDto +import com.devooks.backend.ebook.v1.dto.request.CreateEbookRequest +import com.devooks.backend.ebook.v1.dto.request.ModifyEbookRequest +import com.devooks.backend.ebook.v1.dto.request.SaveDescriptionImagesRequest +import com.devooks.backend.ebook.v1.dto.request.SaveMainImageRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookResponse +import com.devooks.backend.ebook.v1.dto.response.GetDetailOfEbookResponse +import com.devooks.backend.ebook.v1.dto.response.GetEbooksResponse +import com.devooks.backend.ebook.v1.dto.response.ModifyEbookResponse +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse +import com.devooks.backend.ebook.v1.repository.EbookImageRepository +import com.devooks.backend.ebook.v1.repository.EbookRepository +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.notification.v1.adapter.out.persistence.NotificationRepository +import com.devooks.backend.pdf.v1.dto.PdfDto +import com.devooks.backend.pdf.v1.dto.UploadPdfResponse +import com.devooks.backend.pdf.v1.repository.PdfRepository +import com.devooks.backend.pdf.v1.repository.PreviewImageRepository +import com.devooks.backend.review.v1.dto.CreateReviewRequest +import com.devooks.backend.review.v1.dto.CreateReviewResponse +import com.devooks.backend.review.v1.repository.ReviewRepository +import com.devooks.backend.transaciton.v1.domain.PaymentMethod +import com.devooks.backend.transaciton.v1.dto.CreateTransactionRequest +import com.devooks.backend.transaciton.v1.dto.CreateTransactionResponse +import com.devooks.backend.transaciton.v1.repository.TransactionRepository +import com.devooks.backend.wishlist.v1.dto.CreateWishlistRequest +import com.devooks.backend.wishlist.v1.dto.CreateWishlistResponse +import com.devooks.backend.wishlist.v1.repository.WishlistRepository +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.FileSystemResource +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.http.MediaType.MULTIPART_FORM_DATA +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters + +@IntegrationTest +internal class EbookControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val pdfRepository: PdfRepository, + private val previewImageRepository: PreviewImageRepository, + private val ebookRepository: EbookRepository, + private val ebookImageRepository: EbookImageRepository, + private val categoryRepository: CategoryRepository, + private val transactionRepository: TransactionRepository, + private val reviewRepository: ReviewRepository, + private val wishlistRepository: WishlistRepository, + private val notificationRepository: NotificationRepository, +) { + lateinit var expectedMember1: Member + lateinit var expectedMember2: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember1 = memberRepository.save(MemberEntity(nickname = "nickname")).toDomain() + expectedMember2 = memberRepository.save(MemberEntity(nickname = "nickname2")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + wishlistRepository.deleteAll() + reviewRepository.deleteAll() + transactionRepository.deleteAll() + ebookImageRepository.deleteAll() + previewImageRepository.deleteAll() + ebookRepository.deleteAll() + pdfRepository.deleteAll() + memberRepository.deleteAll() + notificationRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `PDF 파일을 저장한 후에 전자책을 생성할 수 있다`(): Unit = runBlocking { + val (request, response) = postCreateEbook() + + val pdfEntity = pdfRepository.findAll().first() + val ebookEntity = ebookRepository.findAll().first() + val descriptionImageEntityList = ebookImageRepository.findAll().toList() + assertThat(response.ebook.pdfId).isEqualTo(pdfEntity.id) + assertThat(response.ebook.id).isEqualTo(ebookEntity.id) + assertThat(response.ebook.title).isEqualTo(request.title) + assertThat(response.ebook.introduction).isEqualTo(request.introduction) + assertThat(response.ebook.price).isEqualTo(request.price) + assertThat(response.ebook.tableOfContents).isEqualTo(request.tableOfContents) + assertThat(response.ebook.relatedCategoryNameList[0].name).isEqualTo(request.relatedCategoryNameList!![0]) + + val mainImageId = response.ebook.mainImageId + assertThat(mainImageId).isEqualTo(ebookEntity.mainImageId) + + + val descriptionImage1 = response.ebook.descriptionImageList[0] + val foundDescriptionImage1 = descriptionImageEntityList.find { it.id == descriptionImage1.id } + assertThat(Path(descriptionImage1.imagePath).exists()).isTrue() + assertThat(descriptionImage1.imagePath).isEqualTo(foundDescriptionImage1!!.imagePath) + + + val descriptionImage2 = response.ebook.descriptionImageList[1] + val foundDescriptionImage2 = descriptionImageEntityList.find { it.id == descriptionImage2.id } + assertThat(Path(descriptionImage2.imagePath).exists()).isTrue() + assertThat(descriptionImage2.imagePath).isEqualTo(foundDescriptionImage2!!.imagePath) + } + + @Test + fun `전자책을 조회할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + + val ebookView = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList[0] + + assertThat(ebookView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookView.mainImagePath).exists()).isTrue() + assertThat(ebookView.title).isEqualTo(response.ebook.title) + assertThat(ebookView.wishlistId).isNull() + assertThat(ebookView.review.rating).isZero() + assertThat(ebookView.review.count).isZero() + assertThat(ebookView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + } + + @Test + fun `전자책이 존재하지 않을 경우 빈 리스트가 조회된다`(): Unit = runBlocking { + val ebookList = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList + + assertThat(ebookList.isEmpty()).isTrue() + } + + @Test + fun `전자책을 삭제할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + webTestClient + .delete() + .uri("/api/v1/ebooks/${response.ebook.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + + assertThat(ebookRepository.findById(response.ebook.id)!!.deletedDate).isNotNull + } + + @Test + fun `삭제된 전자책은 조회되지 않는다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + webTestClient + .delete() + .uri("/api/v1/ebooks/${response.ebook.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + + val ebookList = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList + + assertThat(ebookList.isEmpty()).isTrue() + } + + @Test + fun `전자책 삭제시 자신이 등록한 전자책이 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + webTestClient + .delete() + .uri("/api/v1/ebooks/${response.ebook.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `전자책을 제목으로 조회할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + + val ebookView = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10&title=${response.ebook.title[0]}") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList[0] + + assertThat(ebookView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookView.mainImagePath).exists()).isTrue() + assertThat(ebookView.title).isEqualTo(response.ebook.title) + assertThat(ebookView.wishlistId).isNull() + assertThat(ebookView.review.rating).isZero() + assertThat(ebookView.review.count).isZero() + assertThat(ebookView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + } + + @Test + fun `전자책을 판매자로 조회할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + + val ebookView = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10&sellingMemberId=${response.ebook.sellingMemberId}") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList[0] + + assertThat(ebookView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookView.mainImagePath).exists()).isTrue() + assertThat(ebookView.title).isEqualTo(response.ebook.title) + assertThat(ebookView.wishlistId).isNull() + assertThat(ebookView.review.rating).isZero() + assertThat(ebookView.review.count).isZero() + assertThat(ebookView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + } + + @Test + fun `전자책을 식별자로 조회할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + + val ebookView = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10&ebookIdList=${response.ebook.id}") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList[0] + + assertThat(ebookView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookView.mainImagePath).exists()).isTrue() + assertThat(ebookView.title).isEqualTo(response.ebook.title) + assertThat(ebookView.wishlistId).isNull() + assertThat(ebookView.review.rating).isZero() + assertThat(ebookView.review.count).isZero() + assertThat(ebookView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + } + + @Test + fun `전자책을 카테고리로 조회할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val categoryId = categoryRepository.findAll().toList()[0].id + + + val ebookView = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10&categoryIdList=${categoryId}") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList[0] + + assertThat(ebookView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookView.mainImagePath).exists()).isTrue() + assertThat(ebookView.title).isEqualTo(response.ebook.title) + assertThat(ebookView.wishlistId).isNull() + assertThat(ebookView.review.rating).isZero() + assertThat(ebookView.review.count).isZero() + assertThat(ebookView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + } + + @Test + fun `전자책을 리뷰순으로 조회할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + + val ebookView = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10&orderBy=REVIEW") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList[0] + + assertThat(ebookView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookView.mainImagePath).exists()).isTrue() + assertThat(ebookView.title).isEqualTo(response.ebook.title) + assertThat(ebookView.wishlistId).isNull() + assertThat(ebookView.review.rating).isZero() + assertThat(ebookView.review.count).isZero() + assertThat(ebookView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + } + + @Test + fun `전자책의 리뷰 평점과 개수를 확인할 수 있다`(): Unit = runBlocking { + val (response, accessToken) = postCreateEbookAndCreateTransaction() + + val createReviewRequest = + CreateReviewRequest( + ebookId = response.ebook.id.toString(), + rating = "5", + content = "content" + ) + val createReviewResponse = webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val ebookView = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10&orderBy=REVIEW") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList[0] + + assertThat(ebookView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookView.mainImagePath).exists()).isTrue() + assertThat(ebookView.title).isEqualTo(response.ebook.title) + assertThat(ebookView.wishlistId).isNull() + assertThat(ebookView.review.rating).isEqualTo(createReviewResponse.review.rating.toDouble()) + assertThat(ebookView.review.count).isOne() + assertThat(ebookView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + } + + @Test + fun `전자책을 조회하여 찜 식별자를 조회할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + val wishlistId = webTestClient + .post() + .uri("/api/v1/wishlist") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(CreateWishlistRequest(response.ebook.id.toString())) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .wishlistId + + val ebookView = webTestClient + .get() + .uri("/api/v1/ebooks?page=1&count=10") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookList[0] + + assertThat(ebookView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookView.mainImagePath).exists()).isTrue() + assertThat(ebookView.title).isEqualTo(response.ebook.title) + assertThat(ebookView.wishlistId).isEqualTo(wishlistId) + assertThat(ebookView.review.rating).isZero() + assertThat(ebookView.review.count).isZero() + assertThat(ebookView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + } + + @Test + fun `전자책 상세 조회를 할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + val wishlistId = webTestClient + .post() + .uri("/api/v1/wishlist") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(CreateWishlistRequest(response.ebook.id.toString())) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .wishlistId + + val ebookDetailView = webTestClient + .get() + .uri("/api/v1/ebooks/${response.ebook.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebook + + assertThat(ebookDetailView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookDetailView.mainImagePath).exists()).isTrue() + assertThat(ebookDetailView.title).isEqualTo(response.ebook.title) + assertThat(ebookDetailView.wishlistId).isEqualTo(wishlistId) + assertThat(ebookDetailView.review.rating).isZero() + assertThat(ebookDetailView.review.count).isZero() + assertThat(ebookDetailView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + assertThat(ebookDetailView.price).isEqualTo(response.ebook.price) + assertThat(ebookDetailView.createdDate).isEqualTo(response.ebook.createdDate) + assertThat(ebookDetailView.modifiedDate).isEqualTo(response.ebook.modifiedDate) + assertThat(ebookDetailView.descriptionImagePathList).isEqualTo(response.ebook.descriptionImageList) + assertThat(ebookDetailView.introduction).isEqualTo(response.ebook.introduction) + assertThat(ebookDetailView.tableOfContents).isEqualTo(response.ebook.tableOfContents) + assertThat(ebookDetailView.pdfId).isEqualTo(response.ebook.pdfId) + assertThat(ebookDetailView.pageCount).isEqualTo(9) + assertThat(ebookDetailView.sellingMemberId).isEqualTo(response.ebook.sellingMemberId) + } + + @Test + fun `삭제된 전자책을 상세 조회시 예외가 발생한다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + webTestClient + .delete() + .uri("/api/v1/ebooks/${response.ebook.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + + webTestClient + .get() + .uri("/api/v1/ebooks/${response.ebook.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `설명 이미지가 존재하지 않는 전자책을 상세 조회할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbookWithNoneDescriptionImageList() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + val wishlistId = webTestClient + .post() + .uri("/api/v1/wishlist") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(CreateWishlistRequest(response.ebook.id.toString())) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .wishlistId + + val ebookDetailView = webTestClient + .get() + .uri("/api/v1/ebooks/${response.ebook.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebook + + assertThat(ebookDetailView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookDetailView.mainImagePath).exists()).isTrue() + assertThat(ebookDetailView.title).isEqualTo(response.ebook.title) + assertThat(ebookDetailView.wishlistId).isEqualTo(wishlistId) + assertThat(ebookDetailView.review.rating).isZero() + assertThat(ebookDetailView.review.count).isZero() + assertThat(ebookDetailView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + assertThat(ebookDetailView.price).isEqualTo(response.ebook.price) + assertThat(ebookDetailView.createdDate).isEqualTo(response.ebook.createdDate) + assertThat(ebookDetailView.modifiedDate).isEqualTo(response.ebook.modifiedDate) + assertThat(ebookDetailView.descriptionImagePathList).isNull() + assertThat(ebookDetailView.introduction).isEqualTo(response.ebook.introduction) + assertThat(ebookDetailView.tableOfContents).isEqualTo(response.ebook.tableOfContents) + assertThat(ebookDetailView.pdfId).isEqualTo(response.ebook.pdfId) + assertThat(ebookDetailView.pageCount).isEqualTo(9) + assertThat(ebookDetailView.sellingMemberId).isEqualTo(response.ebook.sellingMemberId) + } + + @Test + fun `전자책 상세 조회하여 리뷰 평점과 개수를 알 수 있다`(): Unit = runBlocking { + val (response, accessToken) = postCreateEbookAndCreateTransaction() + + val createReviewRequest = + CreateReviewRequest( + ebookId = response.ebook.id.toString(), + rating = "5", + content = "content" + ) + val createReviewResponse = webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val ebookDetailView = webTestClient + .get() + .uri("/api/v1/ebooks/${response.ebook.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebook + + assertThat(ebookDetailView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookDetailView.mainImagePath).exists()).isTrue() + assertThat(ebookDetailView.title).isEqualTo(response.ebook.title) + assertThat(ebookDetailView.wishlistId).isNull() + assertThat(ebookDetailView.review.rating).isEqualTo(createReviewResponse.review.rating.toDouble()) + assertThat(ebookDetailView.review.count).isOne() + assertThat(ebookDetailView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + assertThat(ebookDetailView.price).isEqualTo(response.ebook.price) + assertThat(ebookDetailView.createdDate).isEqualTo(response.ebook.createdDate) + assertThat(ebookDetailView.modifiedDate).isEqualTo(response.ebook.modifiedDate) + assertThat(ebookDetailView.descriptionImagePathList).containsAll(response.ebook.descriptionImageList) + assertThat(ebookDetailView.introduction).isEqualTo(response.ebook.introduction) + assertThat(ebookDetailView.tableOfContents).isEqualTo(response.ebook.tableOfContents) + assertThat(ebookDetailView.pdfId).isEqualTo(response.ebook.pdfId) + assertThat(ebookDetailView.pageCount).isEqualTo(9) + assertThat(ebookDetailView.sellingMemberId).isEqualTo(response.ebook.sellingMemberId) + } + + @Test + fun `전자책 상세 조회하여 찜 식별자를 조회할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + + val ebookDetailView = webTestClient + .get() + .uri("/api/v1/ebooks/${response.ebook.id}") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebook + + assertThat(ebookDetailView.id).isEqualTo(response.ebook.id) + assertThat(File(ebookDetailView.mainImagePath).exists()).isTrue() + assertThat(ebookDetailView.title).isEqualTo(response.ebook.title) + assertThat(ebookDetailView.wishlistId).isNull() + assertThat(ebookDetailView.review.rating).isZero() + assertThat(ebookDetailView.review.count).isZero() + assertThat(ebookDetailView.relatedCategoryNameList).contains(response.ebook.relatedCategoryNameList[0].name) + assertThat(ebookDetailView.price).isEqualTo(response.ebook.price) + assertThat(ebookDetailView.createdDate).isEqualTo(response.ebook.createdDate) + assertThat(ebookDetailView.modifiedDate).isEqualTo(response.ebook.modifiedDate) + assertThat(ebookDetailView.descriptionImagePathList).isEqualTo(response.ebook.descriptionImageList) + assertThat(ebookDetailView.introduction).isEqualTo(response.ebook.introduction) + assertThat(ebookDetailView.tableOfContents).isEqualTo(response.ebook.tableOfContents) + assertThat(ebookDetailView.pdfId).isEqualTo(response.ebook.pdfId) + assertThat(ebookDetailView.pageCount).isEqualTo(9) + assertThat(ebookDetailView.sellingMemberId).isEqualTo(response.ebook.sellingMemberId) + } + + @Test + fun `전자책을 수정할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val originEbookEntity = ebookRepository.findById(response.ebook.id)!! + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val newMainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + val newDescriptionImages = postSaveDescriptionImages(imageBase64Raw, imagePath, accessToken) + + val modifyEbookRequest = ModifyEbookRequest( + ebook = ModifyEbookRequest.Ebook( + title = "title2", + relatedCategoryNameList = listOf("category2"), + mainImageId = newMainImage.id.toString(), + descriptionImageIdList = + newDescriptionImages + .map { it.id.toString() } + .plus(response.ebook.descriptionImageList.map { it.id.toString() }.first()), + price = 20000, + tableOfContents = "tableOfContents2", + introduction = "introduction2" + ), + isChanged = ModifyEbookRequest.IsChanged( + title = true, + relatedCategoryNameList = true, + mainImage = true, + descriptionImageList = true, + introduction = true, + tableOfContents = true, + price = true, + ) + ) + + val updatedEbook = webTestClient + .patch() + .uri("/api/v1/ebooks/${response.ebook.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(modifyEbookRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebook + + val updatedEbookEntity = ebookRepository.findById(updatedEbook.id)!! + val descriptionImageRepository = + ebookImageRepository.findAllByEbookId(updatedEbook.id).map { it.toDomain().toDto() } + .filter { it.id != response.ebook.mainImageId } + assertThat(updatedEbook.id).isEqualTo(response.ebook.id) + assertThat(updatedEbook.mainImageId).isEqualTo(updatedEbookEntity.mainImageId) + assertThat(updatedEbook.title).isEqualTo(modifyEbookRequest.ebook!!.title) + assertThat(updatedEbook.relatedCategoryNameList.map { it.name }).containsAll(modifyEbookRequest.ebook!!.relatedCategoryNameList!!) + assertThat(updatedEbook.price).isEqualTo(modifyEbookRequest.ebook!!.price) + assertThat(updatedEbook.descriptionImageList).containsAll(descriptionImageRepository) + assertThat(updatedEbook.introduction).isEqualTo(modifyEbookRequest.ebook!!.introduction) + assertThat(updatedEbook.tableOfContents).isEqualTo(modifyEbookRequest.ebook!!.tableOfContents) + assertThat(updatedEbook.mainImageId).isNotEqualTo(originEbookEntity.mainImageId) + } + + @Test + fun `삭제된 전자책을 수정시 예외가 발생한다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val newMainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + val newDescriptionImages = postSaveDescriptionImages(imageBase64Raw, imagePath, accessToken) + + webTestClient + .delete() + .uri("/api/v1/ebooks/${response.ebook.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + + val modifyEbookRequest = ModifyEbookRequest( + ebook = ModifyEbookRequest.Ebook( + title = "title2", + relatedCategoryNameList = listOf("category2"), + mainImageId = newMainImage.id.toString(), + descriptionImageIdList = + newDescriptionImages + .map { it.id.toString() } + .plus(response.ebook.descriptionImageList.map { it.id.toString() }.first()), + price = 20000, + tableOfContents = "tableOfContents2", + introduction = "introduction2" + ), + isChanged = ModifyEbookRequest.IsChanged( + title = true, + relatedCategoryNameList = true, + mainImage = true, + descriptionImageList = true, + introduction = true, + tableOfContents = true, + price = true, + ) + ) + + webTestClient + .patch() + .uri("/api/v1/ebooks/${response.ebook.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(modifyEbookRequest) + .exchange() + .expectStatus().isNotFound + + } + + @Test + fun `전자책의 제목만 수정할 수 있다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val originEbookEntity = ebookRepository.findById(response.ebook.id)!! + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + val modifyEbookRequest = ModifyEbookRequest( + ebook = ModifyEbookRequest.Ebook( + title = "title2", + ), + isChanged = ModifyEbookRequest.IsChanged( + title = true, + ) + ) + + val updatedEbook = webTestClient + .patch() + .uri("/api/v1/ebooks/${response.ebook.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(modifyEbookRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebook + + val updatedEbookEntity = ebookRepository.findById(updatedEbook.id)!! + val descriptionImageList = + ebookImageRepository.findAllByEbookId(updatedEbook.id).map { it.toDomain().toDto() } + .filter { it.id != response.ebook.mainImageId } + assertThat(updatedEbook.id).isEqualTo(response.ebook.id) + assertThat(updatedEbook.mainImageId).isEqualTo(updatedEbookEntity.mainImageId) + assertThat(updatedEbook.title).isEqualTo(modifyEbookRequest.ebook!!.title) + assertThat(updatedEbook.descriptionImageList).isEqualTo(descriptionImageList) + assertThat(updatedEbook.mainImageId).isEqualTo(originEbookEntity.mainImageId) + } + + @Test + fun `전자책의 제목 수정시 제목이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + val modifyEbookRequest = ModifyEbookRequest( + ebook = ModifyEbookRequest.Ebook(), + isChanged = ModifyEbookRequest.IsChanged( + title = true, + ) + ) + + webTestClient + .patch() + .uri("/api/v1/ebooks/${response.ebook.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(modifyEbookRequest) + .exchange() + .expectStatus().isBadRequest + } + + @Test + fun `전자책 수정시 전자책이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + val modifyEbookRequest = ModifyEbookRequest( + ebook = ModifyEbookRequest.Ebook( + title = "title2", + ), + isChanged = ModifyEbookRequest.IsChanged( + title = true, + ) + ) + + webTestClient + .patch() + .uri("/api/v1/ebooks/${UUID.randomUUID()}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(modifyEbookRequest) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `전자책 수정시 자신이 판매하는 전자책이 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, response) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + val modifyEbookRequest = ModifyEbookRequest( + ebook = ModifyEbookRequest.Ebook( + title = "title2", + ), + isChanged = ModifyEbookRequest.IsChanged( + title = true, + ) + ) + + webTestClient + .patch() + .uri("/api/v1/ebooks/${response.ebook.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(modifyEbookRequest) + .exchange() + .expectStatus().isForbidden + } + + suspend fun postCreateEbookAndCreateTransaction(): Pair { + val (_, response) = postCreateEbook() + val createTransactionRequest = CreateTransactionRequest( + ebookId = response.ebook.id.toString(), + paymentMethod = PaymentMethod.CREDIT_CARD.name, + price = response.ebook.price + ) + + val tokenGroup = tokenService.createTokenGroup(expectedMember2) + val accessToken = tokenGroup.accessToken + + webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(response, accessToken) + } + + suspend fun postCreateEbook(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + val descriptionImageList = postSaveDescriptionImages(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = descriptionImageList.map { it.id.toString() }, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + fun postSaveDescriptionImages( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): List { + val saveDescriptionImagesRequest = SaveDescriptionImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val descriptionImageList = webTestClient + .post() + .uri("/api/v1/ebooks/description-images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveDescriptionImagesRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .descriptionImageList + return descriptionImageList + } + + private fun postSaveMainImage( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): SaveMainImageResponse.MainImageDto { + val saveMainImageRequest = SaveMainImageRequest( + SaveMainImageRequest.MainImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + ) + ) + + val mainImage = webTestClient + .post() + .uri("/api/v1/ebooks/main-image") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveMainImageRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .mainImage + return mainImage + } + + suspend fun postCreateEbookWithNoneDescriptionImageList(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = listOf(), + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + + suspend fun postUploadPdfFile(accessToken: String): PdfDto { + val pdfResource = File(javaClass.classLoader.getResource("valid_test.pdf")!!.path) + + val formData = LinkedMultiValueMap() + formData.add("pdf", FileSystemResource(pdfResource)) + + val pdf = webTestClient + .post() + .uri("/api/v1/pdfs") + .contentType(MULTIPART_FORM_DATA) + .header(AUTHORIZATION, "Bearer $accessToken") + .body(BodyInserters.fromMultipartData(formData)) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .pdf + return pdf + } +} diff --git a/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookImageControllerTest.kt b/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookImageControllerTest.kt new file mode 100644 index 0000000..8654506 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookImageControllerTest.kt @@ -0,0 +1,148 @@ +package com.devooks.backend.ebook.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.ebook.v1.dto.request.SaveDescriptionImagesRequest +import com.devooks.backend.ebook.v1.dto.request.SaveMainImageRequest +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse +import com.devooks.backend.ebook.v1.repository.EbookImageRepository +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import java.io.File +import java.nio.file.Files +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody + +@IntegrationTest +internal class EbookImageControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val ebookImageRepository: EbookImageRepository, +) { + lateinit var expectedMember1: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember1 = memberRepository.save(MemberEntity(nickname = "nickname")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + ebookImageRepository.deleteAll() + memberRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `설명 사진 목록을 저장할 수 있다`(): Unit = runBlocking { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val request = SaveDescriptionImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val descriptionImageList = webTestClient + .post() + .uri("/api/v1/ebooks/description-images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(request) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .descriptionImageList + + descriptionImageList.forEachIndexed { index, image -> + val expected = request.imageList!![index] + assertThat(image.order).isEqualTo(expected.order) + assertThat(File(image.imagePath).exists()).isTrue() + } + } + + @Test + fun `메인 사진을 저장할 수 있다`(): Unit = runBlocking { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val request = SaveMainImageRequest( + SaveMainImageRequest.MainImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + ) + ) + + val mainImage = webTestClient + .post() + .uri("/api/v1/ebooks/main-image") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(request) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .mainImage + + assertThat(File(mainImage.imagePath).exists()).isTrue() + } +} diff --git a/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryCommentControllerTest.kt b/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryCommentControllerTest.kt new file mode 100644 index 0000000..b405f3c --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryCommentControllerTest.kt @@ -0,0 +1,485 @@ +package com.devooks.backend.ebook.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.domain.AccessToken +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.EbookInquiryCommentDto +import com.devooks.backend.ebook.v1.dto.EbookInquiryDto +import com.devooks.backend.ebook.v1.dto.request.CreateEbookInquiryCommentRequest +import com.devooks.backend.ebook.v1.dto.request.CreateEbookInquiryRequest +import com.devooks.backend.ebook.v1.dto.request.CreateEbookRequest +import com.devooks.backend.ebook.v1.dto.request.ModifyEbookInquiryCommentRequest +import com.devooks.backend.ebook.v1.dto.request.SaveDescriptionImagesRequest +import com.devooks.backend.ebook.v1.dto.request.SaveMainImageRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookInquiryCommentResponse +import com.devooks.backend.ebook.v1.dto.response.CreateEbookInquiryResponse +import com.devooks.backend.ebook.v1.dto.response.CreateEbookResponse +import com.devooks.backend.ebook.v1.dto.response.GetEbookInquiryCommentsResponse +import com.devooks.backend.ebook.v1.dto.response.ModifyEbookInquiryCommentResponse +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse +import com.devooks.backend.ebook.v1.repository.EbookImageRepository +import com.devooks.backend.ebook.v1.repository.EbookInquiryCommentRepository +import com.devooks.backend.ebook.v1.repository.EbookInquiryRepository +import com.devooks.backend.ebook.v1.repository.EbookRepository +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.notification.v1.adapter.out.persistence.NotificationRepository +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.pdf.v1.dto.PdfDto +import com.devooks.backend.pdf.v1.dto.UploadPdfResponse +import com.devooks.backend.pdf.v1.repository.PdfRepository +import com.devooks.backend.pdf.v1.repository.PreviewImageRepository +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.FileSystemResource +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.http.MediaType.MULTIPART_FORM_DATA +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters + +@IntegrationTest +internal class EbookInquiryCommentControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val pdfRepository: PdfRepository, + private val previewImageRepository: PreviewImageRepository, + private val ebookRepository: EbookRepository, + private val ebookImageRepository: EbookImageRepository, + private val ebookInquiryRepository: EbookInquiryRepository, + private val ebookInquiryCommentRepository: EbookInquiryCommentRepository, + private val notificationRepository: NotificationRepository, +) { + lateinit var expectedMember1: Member + lateinit var expectedMember2: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember1 = memberRepository.save(MemberEntity(nickname = "nickname1")).toDomain() + expectedMember2 = memberRepository.save(MemberEntity(nickname = "nickname2")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + ebookInquiryCommentRepository.deleteAll() + ebookInquiryRepository.deleteAll() + ebookImageRepository.deleteAll() + previewImageRepository.deleteAll() + ebookRepository.deleteAll() + pdfRepository.deleteAll() + memberRepository.deleteAll() + notificationRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `전자책 문의 댓글을 작성할 수 있다`(): Unit = runBlocking { + val (accessToken, ebookInquiry) = postCreateEbookInquiry() + + val createEbookInquiryCommentRequest = CreateEbookInquiryCommentRequest( + inquiryId = ebookInquiry.id.toString(), + content = "content" + ) + + val ebookInquiryComment = webTestClient + .post() + .uri("/api/v1/ebook-inquiry-comments") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(createEbookInquiryCommentRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .comment + + assertThat(ebookInquiryComment.inquiryId.toString()).isEqualTo(createEbookInquiryCommentRequest.inquiryId) + assertThat(ebookInquiryComment.content).isEqualTo(createEbookInquiryCommentRequest.content) + assertThat(ebookInquiryComment.writerMemberId).isEqualTo(expectedMember1.id) + + val notification = + notificationRepository.findAll().toList().find { it.type == NotificationType.INQUIRY_COMMENT }!! + assertThat(notification.type).isEqualTo(NotificationType.INQUIRY_COMMENT) + assertThat(notification.receiverId).isEqualTo(ebookInquiry.writerMemberId) + assertThat(notification.note["ebookId"]).isEqualTo(ebookInquiry.ebookId.toString()) + assertThat(notification.note["receiverId"]).isEqualTo(ebookInquiry.writerMemberId.toString()) + assertThat(notification.note["commenterName"]).isEqualTo(expectedMember1.nickname) + assertThat(notification.note["ebookInquiryId"]).isEqualTo(ebookInquiry.id.toString()) + assertThat(notification.note["ebookInquiryCommentId"]).isEqualTo(ebookInquiryComment.id.toString()) + + } + + @Test + fun `전자책 문의 댓글을 조회할 수 있다`(): Unit = runBlocking { + val ebookInquiryComment = postCreateEbookInquiryComment() + + val foundEbookInquiryComment = webTestClient + .get() + .uri("/api/v1/ebook-inquiry-comments?inquiryId=${ebookInquiryComment.inquiryId}&page=1&count=10") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .comments[0] + + assertThat(foundEbookInquiryComment).isEqualTo(ebookInquiryComment) + } + + @Test + fun `전자책 문의 댓글을 수정할 수 있다`(): Unit = runBlocking { + val ebookInquiryComment = postCreateEbookInquiryComment() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val modifyEbookInquiryCommentRequest = ModifyEbookInquiryCommentRequest( + "content2" + ) + + val updatedComment = webTestClient + .patch() + .uri("/api/v1/ebook-inquiry-comments/${ebookInquiryComment.id}") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(modifyEbookInquiryCommentRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .comment + + assertThat(updatedComment.content).isEqualTo(modifyEbookInquiryCommentRequest.content) + } + + @Test + fun `전자책 문의 댓글을 삭제할 수 있다`(): Unit = runBlocking { + val ebookInquiryComment = postCreateEbookInquiryComment() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + webTestClient + .delete() + .uri("/api/v1/ebook-inquiry-comments/${ebookInquiryComment.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + } + + @Test + fun `전자책 문의 댓글 삭제시 자신이 작성한 문의가 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val ebookInquiryComment = postCreateEbookInquiryComment() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + webTestClient + .delete() + .uri("/api/v1/ebook-inquiry-comments/${ebookInquiryComment.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `전자책 문의 댓글 수정시 댓글이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val modifyEbookInquiryCommentRequest = ModifyEbookInquiryCommentRequest( + "content2" + ) + + webTestClient + .patch() + .uri("/api/v1/ebook-inquiry-comments/${UUID.randomUUID()}") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(modifyEbookInquiryCommentRequest) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `전자책 문의 댓글 수정시 자신이 작성한 댓글이 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val ebookInquiryComment = postCreateEbookInquiryComment() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + val modifyEbookInquiryCommentRequest = ModifyEbookInquiryCommentRequest( + "content2" + ) + + webTestClient + .patch() + .uri("/api/v1/ebook-inquiry-comments/${ebookInquiryComment.id}") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(modifyEbookInquiryCommentRequest) + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `전자책 문의 댓글 작성시 문의가 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val (accessToken, _) = postCreateEbookInquiry() + + val createEbookInquiryCommentRequest = CreateEbookInquiryCommentRequest( + inquiryId = UUID.randomUUID().toString(), + content = "content" + ) + + webTestClient + .post() + .uri("/api/v1/ebook-inquiry-comments") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(createEbookInquiryCommentRequest) + .exchange() + .expectStatus().isNotFound + } + + private suspend fun EbookInquiryCommentControllerTest.postCreateEbookInquiryComment(): EbookInquiryCommentDto { + val (accessToken, ebookInquiry) = postCreateEbookInquiry() + + val createEbookInquiryCommentRequest = CreateEbookInquiryCommentRequest( + inquiryId = ebookInquiry.id.toString(), + content = "content" + ) + + val ebookInquiryComment = webTestClient + .post() + .uri("/api/v1/ebook-inquiry-comments") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(createEbookInquiryCommentRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .comment + return ebookInquiryComment + } + + private suspend fun EbookInquiryCommentControllerTest.postCreateEbookInquiry(): Pair { + val (_, createEbookResponse) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val createEbookInquiryRequest = CreateEbookInquiryRequest( + ebookId = createEbookResponse.ebook.id.toString(), + content = "content" + ) + + val ebookInquiry = webTestClient + .post() + .uri("/api/v1/ebook-inquiries") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(createEbookInquiryRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookInquiry + return Pair(accessToken, ebookInquiry) + } + + suspend fun postCreateEbook(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + val descriptionImageList = postSaveDescriptionImages(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = descriptionImageList.map { it.id.toString() }, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + fun postSaveDescriptionImages( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): List { + val saveDescriptionImagesRequest = SaveDescriptionImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val descriptionImageList = webTestClient + .post() + .uri("/api/v1/ebooks/description-images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveDescriptionImagesRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .descriptionImageList + return descriptionImageList + } + + private fun postSaveMainImage( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): SaveMainImageResponse.MainImageDto { + val saveMainImageRequest = SaveMainImageRequest( + SaveMainImageRequest.MainImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + ) + ) + + val mainImage = webTestClient + .post() + .uri("/api/v1/ebooks/main-image") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveMainImageRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .mainImage + return mainImage + } + + suspend fun postCreateEbookWithNoneDescriptionImageList(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = null, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + + suspend fun postUploadPdfFile(accessToken: String): PdfDto { + val pdfResource = File(javaClass.classLoader.getResource("valid_test.pdf")!!.path) + + val formData = LinkedMultiValueMap() + formData.add("pdf", FileSystemResource(pdfResource)) + + val pdf = webTestClient + .post() + .uri("/api/v1/pdfs") + .contentType(MULTIPART_FORM_DATA) + .header(AUTHORIZATION, "Bearer $accessToken") + .body(BodyInserters.fromMultipartData(formData)) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .pdf + return pdf + } +} diff --git a/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryControllerTest.kt b/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryControllerTest.kt new file mode 100644 index 0000000..fa85502 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/ebook/v1/controller/EbookInquiryControllerTest.kt @@ -0,0 +1,463 @@ +package com.devooks.backend.ebook.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.domain.AccessToken +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.EbookInquiryDto +import com.devooks.backend.ebook.v1.dto.request.CreateEbookInquiryRequest +import com.devooks.backend.ebook.v1.dto.request.CreateEbookRequest +import com.devooks.backend.ebook.v1.dto.request.ModifyEbookInquiryRequest +import com.devooks.backend.ebook.v1.dto.request.SaveDescriptionImagesRequest +import com.devooks.backend.ebook.v1.dto.request.SaveMainImageRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookInquiryResponse +import com.devooks.backend.ebook.v1.dto.response.CreateEbookResponse +import com.devooks.backend.ebook.v1.dto.response.GetEbookInquiriesResponse +import com.devooks.backend.ebook.v1.dto.response.ModifyEbookInquiryResponse +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse +import com.devooks.backend.ebook.v1.repository.EbookImageRepository +import com.devooks.backend.ebook.v1.repository.EbookInquiryRepository +import com.devooks.backend.ebook.v1.repository.EbookRepository +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.notification.v1.adapter.out.persistence.NotificationRepository +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.pdf.v1.dto.PdfDto +import com.devooks.backend.pdf.v1.dto.UploadPdfResponse +import com.devooks.backend.pdf.v1.repository.PdfRepository +import com.devooks.backend.pdf.v1.repository.PreviewImageRepository +import io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.FileSystemResource +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.http.MediaType.MULTIPART_FORM_DATA +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters + +@IntegrationTest +internal class EbookInquiryControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val pdfRepository: PdfRepository, + private val previewImageRepository: PreviewImageRepository, + private val ebookRepository: EbookRepository, + private val ebookImageRepository: EbookImageRepository, + private val ebookInquiryRepository: EbookInquiryRepository, + private val notificationRepository: NotificationRepository, +) { + lateinit var expectedMember1: Member + lateinit var expectedMember2: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember1 = memberRepository.save(MemberEntity(nickname = "nickname1")).toDomain() + expectedMember2 = memberRepository.save(MemberEntity(nickname = "nickname2")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + ebookInquiryRepository.deleteAll() + ebookImageRepository.deleteAll() + previewImageRepository.deleteAll() + ebookRepository.deleteAll() + pdfRepository.deleteAll() + memberRepository.deleteAll() + notificationRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `전자책 문의를 작성할 수 있다`(): Unit = runBlocking { + val (_, createEbookResponse) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val createEbookInquiryRequest = CreateEbookInquiryRequest( + ebookId = createEbookResponse.ebook.id.toString(), + content = "content" + ) + + val ebookInquiry = webTestClient + .post() + .uri("/api/v1/ebook-inquiries") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(createEbookInquiryRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookInquiry + + assertThat(ebookInquiry.ebookId.toString()).isEqualTo(createEbookInquiryRequest.ebookId) + assertThat(ebookInquiry.content).isEqualTo(createEbookInquiryRequest.content) + assertThat(ebookInquiry.writerMemberId).isEqualTo(expectedMember1.id) + + val notification = notificationRepository.findAll().toList()[0] + assertThat(notification.type).isEqualTo(NotificationType.INQUIRY) + assertThat(notification.receiverId).isEqualTo(createEbookResponse.ebook.sellingMemberId) + assertThat(notification.note["ebookId"]).isEqualTo(createEbookResponse.ebook.id.toString()) + assertThat(notification.note["ebookTitle"]).isEqualTo(createEbookResponse.ebook.title) + assertThat(notification.note["receiverId"]).isEqualTo(createEbookResponse.ebook.sellingMemberId.toString()) + assertThat(notification.note["inquirerName"]).isEqualTo(expectedMember1.nickname) + assertThat(notification.note["ebookInquiryId"]).isEqualTo(ebookInquiry.id.toString()) + } + + @Test + fun `전자책 문의를 조회할 수 있다`(): Unit = runBlocking { + val createdEbookInquiry = postCreateEbookInquiry() + + val foundEbookInquiry = + webTestClient + .get() + .uri("/api/v1/ebook-inquiries?ebookId=${createdEbookInquiry.ebookId}&page=1&count=10") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookInquiryList[0] + + assertThat(foundEbookInquiry).isEqualTo(createdEbookInquiry) + } + + @Test + fun `전자책 문의를 수정할 수 있다`(): Unit = runBlocking { + val createdEbookInquiry = postCreateEbookInquiry() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val request = ModifyEbookInquiryRequest("content2") + + val updatedEbookInquiry = + webTestClient + .patch() + .uri("/api/v1/ebook-inquiries/${createdEbookInquiry.id}") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookInquiry + + assertThat(updatedEbookInquiry.content).isEqualTo(request.content) + } + + @Test + fun `전자책 문의를 삭제할 수 있다`(): Unit = runBlocking { + val createdEbookInquiry = postCreateEbookInquiry() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + webTestClient + .delete() + .uri("/api/v1/ebook-inquiries/${createdEbookInquiry.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + } + + @Test + fun `전자책 문의 삭제시 문의가 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + webTestClient + .delete() + .uri("/api/v1/ebook-inquiries/${UUID.randomUUID()}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `전자책 문의 수정시 문의가 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val request = ModifyEbookInquiryRequest("content2") + + webTestClient + .patch() + .uri("/api/v1/ebook-inquiries/${UUID.randomUUID()}") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(request) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `전자책 문의 수정시 자신이 작성한 문의가 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val createdEbookInquiry = postCreateEbookInquiry() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + val request = ModifyEbookInquiryRequest("content2") + + webTestClient + .patch() + .uri("/api/v1/ebook-inquiries/${createdEbookInquiry.id}") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(request) + .exchange() + .expectStatus().isEqualTo(FORBIDDEN.code()) + } + + @Test + fun `전자책 문의 삭제시 자신이 작성한 문의가 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val createdEbookInquiry = postCreateEbookInquiry() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + webTestClient + .delete() + .uri("/api/v1/ebook-inquiries/${createdEbookInquiry.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isEqualTo(FORBIDDEN.code()) + } + + @Test + fun `전자책 문의 작성시 전자책이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val createEbookInquiryRequest = CreateEbookInquiryRequest( + ebookId = UUID.randomUUID().toString(), + content = "content" + ) + + webTestClient + .post() + .uri("/api/v1/ebook-inquiries") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(createEbookInquiryRequest) + .exchange() + .expectStatus().isNotFound + } + + private suspend fun EbookInquiryControllerTest.postCreateEbookInquiry(): EbookInquiryDto { + val (_, createEbookResponse) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val createEbookInquiryRequest = CreateEbookInquiryRequest( + ebookId = createEbookResponse.ebook.id.toString(), + content = "content" + ) + + val createdEbookInquiry = + webTestClient + .post() + .uri("/api/v1/ebook-inquiries") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(createEbookInquiryRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .ebookInquiry + return createdEbookInquiry + } + + suspend fun postCreateEbook(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + val descriptionImageList = postSaveDescriptionImages(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = descriptionImageList.map { it.id.toString() }, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + fun postSaveDescriptionImages( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): List { + val saveDescriptionImagesRequest = SaveDescriptionImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val descriptionImageList = webTestClient + .post() + .uri("/api/v1/ebooks/description-images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveDescriptionImagesRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .descriptionImageList + return descriptionImageList + } + + private fun postSaveMainImage( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): SaveMainImageResponse.MainImageDto { + val saveMainImageRequest = SaveMainImageRequest( + SaveMainImageRequest.MainImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + ) + ) + + val mainImage = webTestClient + .post() + .uri("/api/v1/ebooks/main-image") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveMainImageRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .mainImage + return mainImage + } + + suspend fun postCreateEbookWithNoneDescriptionImageList(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = null, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + + suspend fun postUploadPdfFile(accessToken: String): PdfDto { + val pdfResource = File(javaClass.classLoader.getResource("valid_test.pdf")!!.path) + + val formData = LinkedMultiValueMap() + formData.add("pdf", FileSystemResource(pdfResource)) + + val pdf = webTestClient + .post() + .uri("/api/v1/pdfs") + .contentType(MULTIPART_FORM_DATA) + .header(AUTHORIZATION, "Bearer $accessToken") + .body(BodyInserters.fromMultipartData(formData)) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .pdf + return pdf + } +} diff --git a/src/test/kotlin/com/devooks/backend/fixture/ErrorResponse.kt b/src/test/kotlin/com/devooks/backend/fixture/ErrorResponse.kt new file mode 100644 index 0000000..34c8ab7 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/fixture/ErrorResponse.kt @@ -0,0 +1,80 @@ +package com.devooks.backend.fixture + +import com.devooks.backend.common.exception.GeneralException +import java.time.Instant +import org.assertj.core.api.Assertions.assertThat +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.WebTestClient.RequestBodyUriSpec +import org.springframework.test.web.reactive.server.expectBody + +internal data class ErrorResponse( + val timestamp: Instant, + val path: String, + val status: Int, + val error: String, + val requestId: String, + val code: String, + val message: String, +) { + internal fun isEqualTo(exception: GeneralException) { + assertThat(status).isEqualTo(exception.status.value()) + assertThat(error).isEqualTo(exception.status.name) + assertThat(code).isEqualTo(exception.code) + assertThat(message).isEqualTo(exception.message) + } + + companion object { + fun WebTestClient.postForBadRequest( + uri: String, + request: String, + token: String? = null, + ): ErrorResponse = post().getResponseBodyAboutBadRequest(uri, request, token) + + fun WebTestClient.patchForBadRequest( + uri: String, + request: String, + token: String? = null, + ): ErrorResponse = patch().getResponseBodyAboutBadRequest(uri, request, token) + + fun WebTestClient.patchForConflict( + uri: String, + request: String, + token: String? = null, + ): ErrorResponse = patch().getResponseBodyAboutConflict(uri, request, token) + + private fun RequestBodyUriSpec.getResponseBodyAboutBadRequest( + uri: String, + request: String, + token: String? = null, + ): ErrorResponse = + this.uri(uri) + .apply { token?.also { header(AUTHORIZATION, it) } } + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isBadRequest + .expectBody() + .returnResult() + .responseBody!! + + private fun RequestBodyUriSpec.getResponseBodyAboutConflict( + uri: String, + request: String, + token: String? = null, + ): ErrorResponse = + this.uri(uri) + .apply { token?.also { header(AUTHORIZATION, it) } } + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isEqualTo(HttpStatus.CONFLICT) + .expectBody() + .returnResult() + .responseBody!! + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/member/v1/controller/MemberControllerTest.kt b/src/test/kotlin/com/devooks/backend/member/v1/controller/MemberControllerTest.kt new file mode 100644 index 0000000..fdf4cff --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/member/v1/controller/MemberControllerTest.kt @@ -0,0 +1,790 @@ +package com.devooks.backend.member.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.domain.Authority +import com.devooks.backend.auth.v1.domain.OauthType +import com.devooks.backend.auth.v1.domain.TokenGroup +import com.devooks.backend.auth.v1.error.AuthError +import com.devooks.backend.auth.v1.repository.OauthInfoRepository +import com.devooks.backend.auth.v1.repository.RefreshTokenRepository +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.category.v1.entity.CategoryEntity +import com.devooks.backend.category.v1.repository.CategoryRepository +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.common.error.CommonError +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.fixture.ErrorResponse +import com.devooks.backend.fixture.ErrorResponse.Companion.patchForBadRequest +import com.devooks.backend.fixture.ErrorResponse.Companion.patchForConflict +import com.devooks.backend.fixture.ErrorResponse.Companion.postForBadRequest +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.dto.GetProfileResponse +import com.devooks.backend.member.v1.dto.ModifyAccountInfoRequest +import com.devooks.backend.member.v1.dto.ModifyAccountInfoResponse +import com.devooks.backend.member.v1.dto.ModifyNicknameRequest +import com.devooks.backend.member.v1.dto.ModifyNicknameResponse +import com.devooks.backend.member.v1.dto.ModifyProfileImageRequest +import com.devooks.backend.member.v1.dto.ModifyProfileImageResponse +import com.devooks.backend.member.v1.dto.ModifyProfileRequest +import com.devooks.backend.member.v1.dto.ModifyProfileResponse +import com.devooks.backend.member.v1.dto.SignUpRequest +import com.devooks.backend.member.v1.dto.SignUpResponse +import com.devooks.backend.member.v1.dto.WithdrawMemberRequest +import com.devooks.backend.member.v1.error.MemberError +import com.devooks.backend.member.v1.repository.FavoriteCategoryRepository +import com.devooks.backend.member.v1.repository.MemberInfoRepository +import com.devooks.backend.member.v1.repository.MemberRepository +import java.io.File +import java.nio.file.Files +import java.time.Instant +import java.util.* +import kotlin.io.path.Path +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertIterableEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody + +@IntegrationTest +internal class MemberControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val memberRepository: MemberRepository, + private val memberInfoRepository: MemberInfoRepository, + private val oauthInfoRepository: OauthInfoRepository, + private val categoryRepository: CategoryRepository, + private val favoriteCategoryRepository: FavoriteCategoryRepository, + private val refreshTokenRepository: RefreshTokenRepository, + private val tokenService: TokenService, +) { + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + refreshTokenRepository.deleteAll() + favoriteCategoryRepository.deleteAll() + memberRepository.deleteAll() + oauthInfoRepository.deleteAll() + categoryRepository.deleteAll() + memberInfoRepository.deleteAll() + } + + @Test + fun `회원가입 할 수 있다`(): Unit = runBlocking { + // given + val request = SignUpRequest( + oauthId = "oauthId", + oauthType = OauthType.NAVER.name, + nickname = "nickname", + favoriteCategories = listOf("category") + ) + + // when + val response = signUp(request) + + // then + assertThat(response.member.nickname).isEqualTo(request.nickname) + assertThat(response.member.authority).isEqualTo(Authority.USER) + assertThat(response.member.profileImagePath).isEqualTo("") + + val category = categoryRepository.findAll().firstOrNull()!! + assertThat(category.name).isEqualTo(request.favoriteCategories!!.first()) + + val favoriteCategory = favoriteCategoryRepository.findAll().firstOrNull()!! + assertThat(favoriteCategory.categoryId).isEqualTo(category.id) + assertThat(favoriteCategory.favoriteMemberId).isEqualTo(response.member.id) + + val refreshToken = refreshTokenRepository.findAll().firstOrNull()!! + assertThat(refreshToken.memberId).isEqualTo(response.member.id) + assertThat(refreshToken.token).isEqualTo(response.tokenGroup.refreshToken) + } + + @Test + fun `oauthId가 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "oauthType" : "NAVER", + "nickname" : "nickname", + "favoriteCategories" : [ "category" ] + } + """.trimIndent() + val response = webTestClient.postForBadRequest("/api/v1/members/signup", request) + + response.isEqualTo(AuthError.REQUIRED_OAUTH_ID.exception) + } + + @Test + fun `oauthId가 빈문자열인 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "oauthId": "", + "oauthType" : "NAVER", + "nickname" : "nickname", + "favoriteCategories" : [ "category" ] + } + """.trimIndent() + val response = webTestClient.postForBadRequest("/api/v1/members/signup", request) + + response.isEqualTo(AuthError.REQUIRED_OAUTH_ID.exception) + } + + @Test + fun `oauthType이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "oauthId" : "oauthId", + "nickname" : "nickname", + "favoriteCategories" : [ "category" ] + } + """.trimIndent() + val response = webTestClient.postForBadRequest("/api/v1/members/signup", request) + + response.isEqualTo(AuthError.INVALID_OAUTH_TYPE.exception) + } + + @Test + fun `oauthType이 잘못된 형식일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "oauthId" : "oauthId", + "oauthType" : " ", + "nickname" : "nickname", + "favoriteCategories" : [ "category" ] + } + """.trimIndent() + val response = webTestClient.postForBadRequest("/api/v1/members/signup", request) + + response.isEqualTo(AuthError.INVALID_OAUTH_TYPE.exception) + } + + @Test + fun `nickname 이 null 일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "oauthId" : "oauthId", + "oauthType" : "NAVER", + "favoriteCategories" : [ "category" ] + } + """.trimIndent() + val response = webTestClient.postForBadRequest("/api/v1/members/signup", request) + + response.isEqualTo(MemberError.REQUIRED_NICKNAME.exception) + } + + @Test + fun `nickname 이 2자 미만일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "oauthId" : "oauthId", + "oauthType" : "NAVER", + "nickname" : "n", + "favoriteCategories" : [ "category" ] + } + """.trimIndent() + val response = webTestClient.postForBadRequest("/api/v1/members/signup", request) + + response.isEqualTo(MemberError.INVALID_NICKNAME.exception) + } + + @Test + fun `nickname 이 12자 초과일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "oauthId" : "oauthId", + "oauthType" : "NAVER", + "nickname" : "1111111111111", + "favoriteCategories" : [ "category" ] + } + """.trimIndent() + val response = webTestClient.postForBadRequest("/api/v1/members/signup", request) + + response.isEqualTo(MemberError.INVALID_NICKNAME.exception) + } + + @Test + fun `favoriteCategories 가 null 일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "oauthId" : "oauthId", + "oauthType" : "NAVER", + "nickname" : "nickname" + } + """.trimIndent() + val response = webTestClient.postForBadRequest("/api/v1/members/signup", request) + + response.isEqualTo(MemberError.REQUIRED_FAVORITE_CATEGORIES.exception) + } + + @Test + fun `닉네임이 이미 존재할 경우 회원가입 실패`(): Unit = runBlocking { + val request = SignUpRequest( + oauthId = "oauthId", + oauthType = "NAVER", + nickname = "nickname", + favoriteCategories = listOf("category") + ) + signUp(request) + + val response = webTestClient + .post() + .uri("/api/v1/members/signup") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request.copy(oauthId = "oauthId2")) + .exchange() + .expectStatus().isEqualTo(HttpStatus.CONFLICT) + .expectBody() + .returnResult() + .responseBody!! + + response.isEqualTo(MemberError.DUPLICATE_NICKNAME.exception) + } + + @Test + fun `정지당한 회원일 경우 회원가입 실패`(): Unit = runBlocking { + val request = SignUpRequest( + oauthId = "oauthId", + oauthType = "NAVER", + nickname = "nickname", + favoriteCategories = listOf("category") + ) + val signUpResponse = signUp(request) + val foundMember = memberRepository.findById(signUpResponse.member.id)!! + memberRepository.save(foundMember.copy(untilSuspensionDate = Instant.MAX)) + + val response = webTestClient + .post() + .uri("/api/v1/members/signup") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request.copy(nickname = "nickname2")) + .exchange() + .expectStatus().isForbidden + .expectBody() + .returnResult() + .responseBody!! + + response.isEqualTo(MemberError.SUSPENDED_MEMBER.exception) + } + + @Test + fun `탈퇴한 회원일 경우 회원가입 실패`(): Unit = runBlocking { + val request = SignUpRequest( + oauthId = "oauthId", + oauthType = "NAVER", + nickname = "nickname", + favoriteCategories = listOf("category") + ) + val signUpResponse = signUp(request) + val foundMember = memberRepository.findById(signUpResponse.member.id)!! + memberRepository.save(foundMember.copy(withdrawalDate = Instant.now())) + + val response = webTestClient + .post() + .uri("/api/v1/members/signup") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request.copy(nickname = "nickname2")) + .exchange() + .expectStatus().isForbidden + .expectBody() + .returnResult() + .responseBody!! + + response.isEqualTo(MemberError.WITHDREW_MEMBER.exception) + } + + @Test + fun `이미 존재하는 회원일 경우 회원가입 실패`(): Unit = runBlocking { + val request = SignUpRequest( + oauthId = "oauthId", + oauthType = "NAVER", + nickname = "nickname", + favoriteCategories = listOf("category") + ) + signUp(request) + + val response = webTestClient + .post() + .uri("/api/v1/members/signup") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request.copy(nickname = "nickname2")) + .exchange() + .expectStatus().isEqualTo(HttpStatus.CONFLICT) + .expectBody() + .returnResult() + .responseBody!! + + response.isEqualTo(AuthError.DUPLICATE_OAUTH_ID.exception) + } + + @Test + fun `계좌정보를 수정할 수 있다`(): Unit = runBlocking { + val (signUpResponse, tokenGroup) = signUp() + val modifyAccountInfoRequest = ModifyAccountInfoRequest( + realName = "이상민", + bank = "농협", + accountNumber = "12312341234", + ) + + val response = webTestClient + .patch() + .uri("/api/v1/members/account") + .header(AUTHORIZATION, "Bearer ${tokenGroup.accessToken}") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(modifyAccountInfoRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val memberInfo = memberInfoRepository.findByMemberId(signUpResponse.member.id)!! + + assertThat(response.bank).isEqualTo(memberInfo.bank) + assertThat(response.accountNumber).isEqualTo(memberInfo.accountNumber) + assertThat(response.realName).isEqualTo(memberInfo.realName) + } + + @Test + fun `프로필 사진을 수정할 수 있다`(): Unit = runBlocking { + val (signUpResponse, tokenGroup) = signUp() + val modifyProfileImageRequest = ModifyProfileImageRequest( + image = ImageDto( + "test", + "png", + 4, + 1 + ) + ) + + val response = webTestClient + .patch() + .uri("/api/v1/members/image") + .header(AUTHORIZATION, "Bearer ${tokenGroup.accessToken}") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(modifyProfileImageRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val member = response.member + assertThat(member.id).isEqualTo(signUpResponse.member.id) + assertThat(member.nickname).isEqualTo(signUpResponse.member.nickname) + assertThat(File(member.profileImagePath).canRead()).isEqualTo(true) + + Files.delete(Path(member.profileImagePath)) + } + + @Test + fun `닉네임을 수정할 수 있다`(): Unit = runBlocking { + val (signUpResponse, tokenGroup) = signUp() + val modifyNicknameRequest = ModifyNicknameRequest( + nickname = "test" + ) + + val response = webTestClient + .patch() + .uri("/api/v1/members/nickname") + .header(AUTHORIZATION, "Bearer ${tokenGroup.accessToken}") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(modifyNicknameRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val member = response.member + assertThat(member.id).isEqualTo(signUpResponse.member.id) + assertThat(member.nickname).isEqualTo(modifyNicknameRequest.nickname) + } + + @Test + fun `프로필을 수정할 수 있다`(): Unit = runBlocking { + val (_, tokenGroup) = signUp() + val modifyProfileRequest = + ModifyProfileRequest( + phoneNumber = "010-1234-1234", + blogLink = "www.naver.com", + instagramLink = "www.instagram.com", + youtubeLink = "www.youtube.com", + introduction = "hello", + favoriteCategoryNames = listOf("category") + ) + + postModifyProfile(tokenGroup, modifyProfileRequest) + } + + @Test + fun `존재하지 않는 카테고리로 관심 카테고리를 설정할 경우 카테고리가 새로 생성된다`(): Unit = runBlocking { + assertThat(categoryRepository.findAllByNameLikeIgnoreCase("category").firstOrNull()).isNull() + + val (_, tokenGroup) = signUp() + val modifyProfileRequest = + ModifyProfileRequest( + phoneNumber = "010-1234-1234", + blogLink = "www.naver.com", + instagramLink = "www.instagram.com", + youtubeLink = "www.youtube.com", + introduction = "hello", + favoriteCategoryNames = listOf("category") + ) + + postModifyProfile(tokenGroup, modifyProfileRequest) + assertThat(categoryRepository.findAllByNameLikeIgnoreCase("category").firstOrNull()?.name) + .isEqualTo("category") + } + + @Test + fun `이미 존재하는 카테고리로 관심 카테고리를 설정할 경우 카테고리가 생성되지 않는다`(): Unit = runBlocking { + categoryRepository.save(CategoryEntity(name = "category")) + + val (_, tokenGroup) = signUp() + val modifyProfileRequest = + ModifyProfileRequest( + phoneNumber = "010-1234-1234", + blogLink = "www.naver.com", + instagramLink = "www.instagram.com", + youtubeLink = "www.youtube.com", + introduction = "hello", + favoriteCategoryNames = listOf("category") + ) + + postModifyProfile(tokenGroup, modifyProfileRequest) + assertThat(categoryRepository.count()).isOne() + } + + @Test + fun `프로필을 조회할 수 있다`(): Unit = runBlocking { + val (signUpResponse, _) = signUp() + + val response = webTestClient + .get() + .uri("/api/v1/members/${signUpResponse.member.id}/profile") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + assertThat(response.memberId).isEqualTo(signUpResponse.member.id) + assertThat(response.nickname).isEqualTo(signUpResponse.member.nickname) + assertThat(response.profileImagePath).isEqualTo(signUpResponse.member.profileImagePath) + assertThat(response.favoriteCategories.firstOrNull()).isEqualTo("category") + assertThat(response.profile.blogLink).isEqualTo("") + assertThat(response.profile.youtubeLink).isEqualTo("") + assertThat(response.profile.introduction).isEqualTo("") + assertThat(response.profile.instagramLink).isEqualTo("") + } + + @Test + fun `프로필 조회시 회원이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + webTestClient + .get() + .uri("/api/v1/members/${UUID.randomUUID()}/profile") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `회원 탈퇴할 수 있다`(): Unit = runBlocking { + val (response, tokenGroup) = signUp() + + webTestClient + .patch() + .uri("/api/v1/members/withdrawal") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(WithdrawMemberRequest(withdrawalReason = "reason")) + .header(AUTHORIZATION, "Bearer ${tokenGroup.accessToken}") + .exchange() + .expectStatus().isOk + + val member = memberRepository.findById(response.member.id) + assertThat(member!!.withdrawalDate).isNotNull() + } + + @Test + fun `닉네임 수정시 nickname 이 비어 있을 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "nickname" : "" + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/nickname", request, tokenGroup.accessToken) + + response.isEqualTo(MemberError.REQUIRED_NICKNAME.exception) + } + + @Test + fun `닉네임 수정시 nickname 이 이미 존재할 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, tokenGroup) = signUp() + val request = """ + { + "nickname" : "nickname" + } + """.trimIndent() + val response = + webTestClient + .patchForConflict("/api/v1/members/nickname", request, tokenGroup.accessToken) + + response.isEqualTo(MemberError.DUPLICATE_NICKNAME.exception) + } + + @Test + fun `image 가 null 일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/image", request, tokenGroup.accessToken) + + response.isEqualTo(CommonError.REQUIRED_IMAGE.exception) + } + + @Test + fun `base64Raw 가 비어있을 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "image" : { + "base64Raw" : "", + "extension" : "PNG", + "byteSize" : 4 + } + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/image", request, tokenGroup.accessToken) + + response.isEqualTo(CommonError.REQUIRED_BASE64RAW.exception) + } + + @Test + fun `extension 이 JPG, PNG, JPEG 이 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "image" : { + "base64Raw" : "test", + "extension" : "JJP", + "byteSize" : 4 + } + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/image", request, tokenGroup.accessToken) + + response.isEqualTo(CommonError.INVALID_IMAGE_EXTENSION.exception) + } + + @Test + fun `byteSize 가 50MB가 넘을 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "image" : { + "base64Raw" : "test", + "extension" : "PNG", + "byteSize" : 51000000 + } + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/image", request, tokenGroup.accessToken) + + response.isEqualTo(CommonError.INVALID_BYTE_SIZE.exception) + } + + @Test + fun `realName 이 null 일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "bank" : "bank", + "accountNumber" : "accountNumber" + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/account", request, tokenGroup.accessToken) + + response.isEqualTo(MemberError.REQUIRED_REAL_NAME.exception) + } + + @Test + fun `realName 이 비어있을 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "realName" : "", + "bank" : "bank", + "accountNumber" : "accountNumber" + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/account", request, tokenGroup.accessToken) + + response.isEqualTo(MemberError.REQUIRED_REAL_NAME.exception) + } + + @Test + fun `bank 가 null 일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "realName" : "realName", + "accountNumber" : "accountNumber" + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/account", request, tokenGroup.accessToken) + + response.isEqualTo(MemberError.REQUIRED_BANK.exception) + } + + @Test + fun `bank 가 비어있을 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "bank" : "", + "realName" : "realName", + "accountNumber" : "accountNumber" + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/account", request, tokenGroup.accessToken) + + response.isEqualTo(MemberError.REQUIRED_BANK.exception) + } + + @Test + fun `accountNumber 가 null 일 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "bank" : "bank", + "realName" : "realName" + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/account", request, tokenGroup.accessToken) + + response.isEqualTo(MemberError.REQUIRED_ACCOUNT_NUMBER.exception) + } + + @Test + fun `accountNumber 가 비어있을 경우 예외가 발생한다`(): Unit = runBlocking { + val request = """ + { + "bank" : "bank", + "realName" : "realName", + "accountNumber" : "" + } + """.trimIndent() + val (_, tokenGroup) = signUp() + val response = + webTestClient + .patchForBadRequest("/api/v1/members/account", request, tokenGroup.accessToken) + + response.isEqualTo(MemberError.REQUIRED_ACCOUNT_NUMBER.exception) + } + + private suspend fun signUp(): Pair { + val signUpRequest = SignUpRequest( + oauthId = "oauthId", + oauthType = "NAVER", + nickname = "nickname", + favoriteCategories = listOf("category") + ) + val signUpResponse = signUp(signUpRequest) + val member = memberRepository.findById(signUpResponse.member.id)!!.toDomain() + val tokenGroup = tokenService.createTokenGroup(member) + return Pair(signUpResponse, tokenGroup) + } + + private fun signUp(request: SignUpRequest) = webTestClient + .post() + .uri("/api/v1/members/signup") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + private fun postModifyProfile( + tokenGroup: TokenGroup, + modifyProfileRequest: ModifyProfileRequest, + ) { + val response = webTestClient + .patch() + .uri("/api/v1/members/profile") + .header(AUTHORIZATION, "Bearer ${tokenGroup.accessToken}") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(modifyProfileRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val memberInfo = response.memberInfo + val favoriteCategories = response.favoriteCategories.map { it.name } + + assertThat(memberInfo.phoneNumber).isEqualTo(modifyProfileRequest.phoneNumber) + assertThat(memberInfo.blogLink).isEqualTo(modifyProfileRequest.blogLink) + assertThat(memberInfo.instagramLink).isEqualTo(modifyProfileRequest.instagramLink) + assertThat(memberInfo.youtubeLink).isEqualTo(modifyProfileRequest.youtubeLink) + assertThat(memberInfo.introduction).isEqualTo(modifyProfileRequest.introduction) + assertIterableEquals(favoriteCategories, modifyProfileRequest.favoriteCategoryNames) + } +} diff --git a/src/test/kotlin/com/devooks/backend/member/v1/service/FavoriteCategoryServiceTest.kt b/src/test/kotlin/com/devooks/backend/member/v1/service/FavoriteCategoryServiceTest.kt new file mode 100644 index 0000000..6e64ef0 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/member/v1/service/FavoriteCategoryServiceTest.kt @@ -0,0 +1,58 @@ +package com.devooks.backend.member.v1.service + +import com.devooks.backend.category.v1.domain.Category.Companion.toDomain +import com.devooks.backend.category.v1.entity.CategoryEntity +import com.devooks.backend.category.v1.repository.CategoryRepository +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.FavoriteCategoryRepository +import com.devooks.backend.member.v1.repository.MemberRepository +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired + +@IntegrationTest +internal class FavoriteCategoryServiceTest @Autowired constructor( + private val favoriteCategoryService: FavoriteCategoryService, + private val favoriteCategoryRepository: FavoriteCategoryRepository, + private val memberRepository: MemberRepository, + private val categoryRepository: CategoryRepository, +) { + + lateinit var savedMember: MemberEntity + lateinit var savedCategory: CategoryEntity + + @BeforeEach + fun setUp(): Unit = runBlocking { + savedMember = memberRepository.save(MemberEntity(nickname = "nickname")) + savedCategory = categoryRepository.save(CategoryEntity(name = "category")) + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + favoriteCategoryRepository.deleteAll() + memberRepository.deleteAll() + categoryRepository.deleteAll() + } + + @Test + fun `관심있는 카테고리를 등록할 수 있다`(): Unit = runBlocking { + // given + val categories = listOf(savedCategory.toDomain()) + val memberId = savedMember.id!! + + // when + val favoriteCategoryList = favoriteCategoryService.save(categories, memberId) + + // then + val expected = favoriteCategoryRepository.findAll().toList()[0] + val actual = favoriteCategoryList[0] + assertThat(actual.id).isEqualTo(expected.id) + assertThat(actual.categoryId).isEqualTo(expected.categoryId) + assertThat(actual.memberId).isEqualTo(expected.favoriteMemberId) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/notification/v1/adapter/in/event/NotificationEventListenerTest.kt b/src/test/kotlin/com/devooks/backend/notification/v1/adapter/in/event/NotificationEventListenerTest.kt new file mode 100644 index 0000000..da1a6be --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/notification/v1/adapter/in/event/NotificationEventListenerTest.kt @@ -0,0 +1,55 @@ +package com.devooks.backend.notification.v1.adapter.`in`.event + +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.notification.v1.adapter.out.persistence.NotificationRepository +import com.devooks.backend.notification.v1.domain.event.CreateReviewEvent +import java.time.Instant +import java.util.* +import kotlin.test.AfterTest +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationEventPublisher + +@IntegrationTest +internal class NotificationEventListenerTest @Autowired constructor( + private val notificationRepository: NotificationRepository, + private val applicationEventPublisher: ApplicationEventPublisher, +) { + + @AfterTest + fun tearDown() = runTest { + notificationRepository.deleteAll() + } + + @Test + fun `도메인 생성 이벤트를 처리할 수 있다`(): Unit = runBlocking { + // given + val expected = CreateReviewEvent( + receiverId = UUID.randomUUID(), + reviewId = UUID.randomUUID(), + reviewerName = "reviewerName", + ebookId = UUID.randomUUID(), + ebookTitle = "postTitle", + writtenDate = Instant.now(), + ) + + // when + applicationEventPublisher.publishEvent(expected) + delay(100) + + // then + val notifications = notificationRepository.findAll().toList() + val actual = notifications[0] + + assertThat(notifications.size).isOne() + assertThat(actual.content).isEqualTo(expected.content) + assertThat(actual.note).isEqualTo(expected.createNote()) + assertThat(actual.receiverId).isEqualTo(expected.receiverId) + } + +} diff --git a/src/test/kotlin/com/devooks/backend/notification/v1/adapter/in/http/NotificationRouterTest.kt b/src/test/kotlin/com/devooks/backend/notification/v1/adapter/in/http/NotificationRouterTest.kt new file mode 100644 index 0000000..9215fa5 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/notification/v1/adapter/in/http/NotificationRouterTest.kt @@ -0,0 +1,155 @@ +package com.devooks.backend.notification.v1.adapter.`in`.http + +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.PageResponse +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.notification.v1.adapter.`in`.dto.CheckNotificationResponse +import com.devooks.backend.notification.v1.adapter.`in`.dto.NotificationResponse +import com.devooks.backend.notification.v1.adapter.out.persistence.NotificationEntity +import com.devooks.backend.notification.v1.adapter.out.persistence.NotificationRepository +import com.devooks.backend.notification.v1.domain.Notification +import com.devooks.backend.notification.v1.domain.NotificationType +import java.time.Instant +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.http.MediaType.TEXT_EVENT_STREAM +import org.springframework.http.codec.ServerSentEvent +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody + +@IntegrationTest +internal class NotificationRouterTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val memberRepository: MemberRepository, + private val notificationRepository: NotificationRepository, + private val tokenService: TokenService, +) { + lateinit var expectedMember: Member + lateinit var expectedNotification: Notification + + @BeforeTest + fun setup() = runTest { + val memberEntity = MemberEntity(nickname = "nickname") + expectedMember = memberRepository.save(memberEntity).toDomain() + val notificationEntity = NotificationEntity( + type = NotificationType.REVIEW, + content = "content", + note = mapOf("key" to "value"), + receiverId = expectedMember.id, + notifiedDate = Instant.now(), + checked = false + ) + expectedNotification = notificationRepository.save(notificationEntity).toDomain() + } + + @AfterTest + fun tearDown() = runTest { + notificationRepository.deleteAll() + memberRepository.deleteAll() + } + + @Test + fun `실시간으로 읽지 않은 알림의 개수를 조회할 수 있다`() = runTest { + // given + val accessToken = tokenService.createTokenGroup(expectedMember).accessToken + + // when + val eventStream = webTestClient + .get() + .uri("/api/v1/notifications/count") + .accept(TEXT_EVENT_STREAM) + .header(AUTHORIZATION, accessToken) + .exchange() + .expectStatus().isOk + .returnResult(ServerSentEvent::class.java) + .responseBody + .blockFirst()!! + + // then + val streamCountResponse = eventStream.data() as LinkedHashMap<*, *> + assertThat(streamCountResponse["countOfUncheckedNotification"]).isEqualTo(1) + } + + @Test + fun `알림 목록을 조회할 수 있다`() = runTest { + // given + val accessToken = tokenService.createTokenGroup(expectedMember).accessToken + + // when + val notificationsResponse = webTestClient + .get() + .uri("/api/v1/notifications?page=1&size=10") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, accessToken) + .exchange() + .expectBody>() + .returnResult() + .responseBody!! + .data + + + // then + val actual = notificationsResponse[0] + assertThat(actual.id).isEqualTo(expectedNotification.id) + assertThat(actual.content).isEqualTo(expectedNotification.content) + assertThat(actual.note).isEqualTo(expectedNotification.note) + assertThat(actual.type).isEqualTo(expectedNotification.type) + assertThat(actual.receiverId).isEqualTo(expectedNotification.receiverId) + assertThat(actual.checked).isEqualTo(expectedNotification.checked) + assertThat(actual.notifiedDate).isEqualTo(expectedNotification.notifiedDate) + } + + @Test + fun `읽지 않은 모든 알림의 읽음 상태를 변경 요청할 수 있다`() = runTest { + // given + val accessToken = tokenService.createTokenGroup(expectedMember).accessToken + + // when + val count = webTestClient + .patch() + .uri("/api/v1/notifications/checked") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, accessToken) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .count + + // then + assertThat(count).isOne() + } + + @Test + fun `읽지 않은 특정 알림의 읽음 상태를 변경 요청할 수 있다`() = runTest { + // given + val accessToken = tokenService.createTokenGroup(expectedMember).accessToken + + // when + val count = webTestClient + .patch() + .uri("/api/v1/notifications/${expectedNotification.id}/checked") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, accessToken) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .count + + // then + assertThat(count).isOne() + } +} diff --git a/src/test/kotlin/com/devooks/backend/pdf/v1/controller/PdfControllerTest.kt b/src/test/kotlin/com/devooks/backend/pdf/v1/controller/PdfControllerTest.kt new file mode 100644 index 0000000..5b5f3ca --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/pdf/v1/controller/PdfControllerTest.kt @@ -0,0 +1,141 @@ +package com.devooks.backend.pdf.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.pdf.v1.dto.GetPreviewImageListResponse +import com.devooks.backend.pdf.v1.dto.PdfDto +import com.devooks.backend.pdf.v1.dto.UploadPdfResponse +import com.devooks.backend.pdf.v1.repository.PdfRepository +import com.devooks.backend.pdf.v1.repository.PreviewImageRepository +import java.io.File +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.FileSystemResource +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.MULTIPART_FORM_DATA +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters + +@IntegrationTest +internal class PdfControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val pdfRepository: PdfRepository, + private val previewImageRepository: PreviewImageRepository, +) { + + lateinit var expectedMember: Member + + @BeforeEach + fun setUp(): Unit = runBlocking { + expectedMember = memberRepository.save(MemberEntity(nickname = "nickname")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + previewImageRepository.deleteAll() + pdfRepository.deleteAll() + memberRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `PDF 파일을 저장할 수 있다`(): Unit = runBlocking { + val pdf = postUploadPdfFile() + + val pdfPath = Path(pdf.pdfInfo.filePath) + + assertThat(pdf.uploadMemberId).isEqualTo(expectedMember.id) + assertThat(pdfPath.exists()).isTrue() + assertThat(pdf.pdfInfo.pageCount).isEqualTo(9) + pdf.previewImageList.forEach { previewImage -> + val imagePath = Path(previewImage.imagePath) + assertThat(imagePath.exists()).isTrue() + assertThat(previewImage.pdfId).isEqualTo(pdf.id) + assertThat(previewImage.previewOrder).isNotZero() + } + } + + @Test + fun `미리보기 파일을 조회할 수 있다`(): Unit = runBlocking { + val pdf = postUploadPdfFile() + + val previewImageList = webTestClient + .get() + .uri("/api/v1/pdfs/${pdf.id}/preview") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .previewImageList + + previewImageList.forEach { + assertThat(Path(it.imagePath).exists()).isTrue() + assertThat(it.pdfId).isEqualTo(pdf.id) + assertThat(it.previewOrder).isNotZero() + } + } + + @Test + fun `미리보기 파일 조회시 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + webTestClient + .get() + .uri("/api/v1/pdfs/${UUID.randomUUID()}/preview") + .exchange() + .expectStatus().isNotFound + } + + private suspend fun postUploadPdfFile(): PdfDto { + val tokenGroup = tokenService.createTokenGroup(expectedMember) + val pdfResource = File(javaClass.classLoader.getResource("valid_test.pdf")!!.path) + + val formData = LinkedMultiValueMap() + formData.add("pdf", FileSystemResource(pdfResource)) + + val pdf = webTestClient + .post() + .uri("/api/v1/pdfs") + .contentType(MULTIPART_FORM_DATA) + .header(AUTHORIZATION, "Bearer ${tokenGroup.accessToken}") + .body(BodyInserters.fromMultipartData(formData)) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .pdf + return pdf + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/pdf/v1/service/PdfResolverTest.kt b/src/test/kotlin/com/devooks/backend/pdf/v1/service/PdfResolverTest.kt new file mode 100644 index 0000000..fed522f --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/pdf/v1/service/PdfResolverTest.kt @@ -0,0 +1,91 @@ +package com.devooks.backend.pdf.v1.service + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.common.CustomFilePart +import com.devooks.backend.common.assertThrows +import com.devooks.backend.pdf.v1.domain.PdfInfo +import com.devooks.backend.pdf.v1.error.PdfError +import java.io.File +import kotlin.io.path.exists +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.http.codec.multipart.FilePart + +internal class PdfResolverTest { + + private val pdfResolver = PdfResolver() + private val validPdfFilePath = object {}.javaClass.classLoader.getResource("valid_test.pdf")!!.file + private val invalidPdfFilePath1 = object {}.javaClass.classLoader.getResource("invalid_test1.pdf")!!.file + private val invalidPdfFilePath2 = object {}.javaClass.classLoader.getResource("invalid_test2.pdf")!!.file + + companion object { + @JvmStatic + @BeforeAll + fun setUp(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDown(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `PDF 파일을 저장할 수 있다`(): Unit = runBlocking { + val file = File(validPdfFilePath) + val filePart: FilePart = getFilePart(file) + + val savedPdf = pdfResolver.savePdf(filePart) + + assertThat(savedPdf.filePath.exists()).isEqualTo(true) + assertThat(savedPdf.pageCount).isEqualTo(9) + } + + @Test + fun `PDF 파일이 비어있을 경우 예외가 발생한다`(): Unit = runBlocking { + val file = File(invalidPdfFilePath1) + val multipartFile = getFilePart(file) + + assertThrows(PdfError.INVALID_PDF_FILE_SIZE.exception) { + pdfResolver.savePdf(multipartFile) + } + } + + @Test + fun `PDF 파일의 페이지가 5 미만일 경우 예외가 발생한다`(): Unit = runBlocking { + val file = File(invalidPdfFilePath2) + val multipartFile = getFilePart(file) + + assertThrows(PdfError.INVALID_PDF_FILE_PAGE_COUNT.exception) { + pdfResolver.savePdf(multipartFile) + } + } + + @Test + fun `미리보기 파일을 저장할 수 있다`(): Unit = runBlocking { + val file = File(validPdfFilePath) + val filePart: FilePart = getFilePart(file) + + val savedPdf: PdfInfo = pdfResolver.savePdf(filePart) + + assertThat(savedPdf.filePath.exists()).isTrue() + assertThat(savedPdf.pageCount).isEqualTo(9) + + val images = pdfResolver.savePreviewImages(savedPdf) + + images.forEach { image -> + assertThat(image.imagePath.exists()).isTrue() + assertThat(image.order).isNotZero() + } + } + + private fun getFilePart(file: File): FilePart { + return CustomFilePart(file) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/review/v1/controller/ReviewCommentControllerTest.kt b/src/test/kotlin/com/devooks/backend/review/v1/controller/ReviewCommentControllerTest.kt new file mode 100644 index 0000000..70c21d2 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/review/v1/controller/ReviewCommentControllerTest.kt @@ -0,0 +1,508 @@ +package com.devooks.backend.review.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.domain.AccessToken +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.request.CreateEbookRequest +import com.devooks.backend.ebook.v1.dto.request.SaveDescriptionImagesRequest +import com.devooks.backend.ebook.v1.dto.request.SaveMainImageRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookResponse +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse +import com.devooks.backend.ebook.v1.repository.EbookImageRepository +import com.devooks.backend.ebook.v1.repository.EbookRepository +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.notification.v1.adapter.out.persistence.NotificationRepository +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.pdf.v1.dto.PdfDto +import com.devooks.backend.pdf.v1.dto.UploadPdfResponse +import com.devooks.backend.pdf.v1.repository.PdfRepository +import com.devooks.backend.pdf.v1.repository.PreviewImageRepository +import com.devooks.backend.review.v1.dto.CreateReviewCommentRequest +import com.devooks.backend.review.v1.dto.CreateReviewCommentResponse +import com.devooks.backend.review.v1.dto.CreateReviewRequest +import com.devooks.backend.review.v1.dto.CreateReviewResponse +import com.devooks.backend.review.v1.dto.GetReviewCommentsResponse +import com.devooks.backend.review.v1.dto.ModifyReviewCommentRequest +import com.devooks.backend.review.v1.dto.ModifyReviewCommentResponse +import com.devooks.backend.review.v1.dto.ReviewCommentDto +import com.devooks.backend.review.v1.dto.ReviewDto +import com.devooks.backend.review.v1.repository.ReviewCommentRepository +import com.devooks.backend.review.v1.repository.ReviewRepository +import com.devooks.backend.transaciton.v1.domain.PaymentMethod +import com.devooks.backend.transaciton.v1.dto.CreateTransactionRequest +import com.devooks.backend.transaciton.v1.dto.CreateTransactionResponse +import com.devooks.backend.transaciton.v1.repository.TransactionRepository +import io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.FileSystemResource +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.http.MediaType.MULTIPART_FORM_DATA +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters + +@IntegrationTest +internal class ReviewCommentControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val pdfRepository: PdfRepository, + private val previewImageRepository: PreviewImageRepository, + private val ebookRepository: EbookRepository, + private val ebookImageRepository: EbookImageRepository, + private val transactionRepository: TransactionRepository, + private val reviewRepository: ReviewRepository, + private val reviewCommentRepository: ReviewCommentRepository, + private val notificationRepository: NotificationRepository, +) { + lateinit var expectedMember1: Member + lateinit var expectedMember2: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember1 = memberRepository.save(MemberEntity(nickname = "nickname1")).toDomain() + expectedMember2 = memberRepository.save(MemberEntity(nickname = "nickname2")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + reviewCommentRepository.deleteAll() + reviewRepository.deleteAll() + transactionRepository.deleteAll() + ebookImageRepository.deleteAll() + previewImageRepository.deleteAll() + ebookRepository.deleteAll() + pdfRepository.deleteAll() + memberRepository.deleteAll() + notificationRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `리뷰 댓글을 작성할 수 있다`(): Unit = runBlocking { + val review = postCreateReview() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + val request = CreateReviewCommentRequest(review.id.toString(), review.content) + + val reviewComment = webTestClient + .post() + .uri("/api/v1/review-comments") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .reviewComment + + assertThat(reviewComment.reviewId.toString()).isEqualTo(request.reviewId) + assertThat(reviewComment.content).isEqualTo(request.content) + + val notification = notificationRepository.findAll().toList().find { it.receiverId == review.writerMemberId }!! + assertThat(notification.type).isEqualTo(NotificationType.REVIEW_COMMENT) + assertThat(notification.receiverId).isEqualTo(review.writerMemberId) + assertThat(notification.note["ebookId"]).isEqualTo(review.ebookId.toString()) + assertThat(notification.note["reviewId"]).isEqualTo(review.id.toString()) + assertThat(notification.note["receiverId"]).isEqualTo(review.writerMemberId.toString()) + assertThat(notification.note["commenterName"]).isEqualTo(expectedMember2.nickname) + } + + @Test + fun `리뷰 댓글 작성시 리뷰가 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val review = postCreateReview() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + val request = CreateReviewCommentRequest(UUID.randomUUID().toString(), review.content) + + webTestClient + .post() + .uri("/api/v1/review-comments") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(request) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `리뷰 댓글을 조회할 수 있다`(): Unit = runBlocking { + val (request, response) = postCreateReviewComment() + + assertThat(response.id).isEqualTo(request.id) + assertThat(response.content).isEqualTo(request.content) + assertThat(response.reviewId).isEqualTo(request.reviewId) + assertThat(response.writtenDate).isEqualTo(request.writtenDate) + assertThat(response.modifiedDate).isEqualTo(request.modifiedDate) + assertThat(response.writerMemberId).isEqualTo(request.writerMemberId) + } + + @Test + fun `리뷰 댓글을 수정할 수 있다`(): Unit = runBlocking { + val (_, createReviewCommentResponse) = postCreateReviewComment() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + val request = ModifyReviewCommentRequest("content") + + val modifyReviewCommentResponse = webTestClient + .patch() + .uri("/api/v1/review-comments/${createReviewCommentResponse.id}") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .reviewComment + + assertThat(modifyReviewCommentResponse.reviewId).isEqualTo(createReviewCommentResponse.reviewId) + assertThat(modifyReviewCommentResponse.content).isEqualTo(createReviewCommentResponse.content) + } + + @Test + fun `리뷰 댓글을 삭제할 수 있다`(): Unit = runBlocking { + val (_, createReviewCommentResponse) = postCreateReviewComment() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + webTestClient + .delete() + .uri("/api/v1/review-comments/${createReviewCommentResponse.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + } + + @Test + fun `리뷰 댓글 삭제시 자신이 작성한 댓글이 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, createReviewCommentResponse) = postCreateReviewComment() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + webTestClient + .delete() + .uri("/api/v1/review-comments/${createReviewCommentResponse.id}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `리뷰 댓글 수정시 자신이 작성한 댓글이 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, createReviewCommentResponse) = postCreateReviewComment() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + val request = ModifyReviewCommentRequest("content") + + webTestClient + .patch() + .uri("/api/v1/review-comments/${createReviewCommentResponse.id}") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(request) + .exchange() + .expectStatus().isEqualTo(FORBIDDEN.code()) + } + + @Test + fun `리뷰 댓글 작성시 댓글이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + val request = ModifyReviewCommentRequest("content") + + webTestClient + .patch() + .uri("/api/v1/review-comments/${UUID.randomUUID()}") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(request) + .exchange() + .expectStatus().isNotFound + } + + private suspend fun postCreateReviewComment(): Pair { + val review = postCreateReview() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + val createReviewCommentRequest = CreateReviewCommentRequest(review.id.toString(), review.content) + + val reviewComment = webTestClient + .post() + .uri("/api/v1/review-comments") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .bodyValue(createReviewCommentRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .reviewComment + + val response = webTestClient + .get() + .uri("/api/v1/review-comments?reviewId=${review.id}&page=1&count=10") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .reviewComments[0] + return Pair(reviewComment, response) + } + + private suspend fun postCreateReview(): ReviewDto { + val (createEbookResponse, accessToken) = postCreateEbookAndCreateTransaction() + + val createReviewRequest = + CreateReviewRequest( + ebookId = createEbookResponse.ebook.id.toString(), + rating = "5", + content = "content" + ) + val createReviewResponse = webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return createReviewResponse.review + } + + private suspend fun postCreateEbookAndCreateTransaction(): Pair { + val (_, createEbookResponse) = postCreateEbook() + val createTransactionRequest = CreateTransactionRequest( + ebookId = createEbookResponse.ebook.id.toString(), + paymentMethod = PaymentMethod.CREDIT_CARD.name, + price = createEbookResponse.ebook.price + ) + + val tokenGroup = tokenService.createTokenGroup(expectedMember2) + val accessToken = tokenGroup.accessToken + + webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(createEbookResponse, accessToken) + } + + suspend fun postCreateEbook(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + val descriptionImageList = postSaveDescriptionImages(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = descriptionImageList.map { it.id.toString() }, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + fun postSaveDescriptionImages( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): List { + val saveDescriptionImagesRequest = SaveDescriptionImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val descriptionImageList = webTestClient + .post() + .uri("/api/v1/ebooks/description-images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveDescriptionImagesRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .descriptionImageList + return descriptionImageList + } + + private fun postSaveMainImage( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): SaveMainImageResponse.MainImageDto { + val saveMainImageRequest = SaveMainImageRequest( + SaveMainImageRequest.MainImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + ) + ) + + val mainImage = webTestClient + .post() + .uri("/api/v1/ebooks/main-image") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveMainImageRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .mainImage + return mainImage + } + + suspend fun postCreateEbookWithNoneDescriptionImageList(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = null, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + + suspend fun postUploadPdfFile(accessToken: String): PdfDto { + val pdfResource = File(javaClass.classLoader.getResource("valid_test.pdf")!!.path) + + val formData = LinkedMultiValueMap() + formData.add("pdf", FileSystemResource(pdfResource)) + + val pdf = webTestClient + .post() + .uri("/api/v1/pdfs") + .contentType(MULTIPART_FORM_DATA) + .header(AUTHORIZATION, "Bearer $accessToken") + .body(BodyInserters.fromMultipartData(formData)) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .pdf + return pdf + } +} diff --git a/src/test/kotlin/com/devooks/backend/review/v1/controller/ReviewControllerTest.kt b/src/test/kotlin/com/devooks/backend/review/v1/controller/ReviewControllerTest.kt new file mode 100644 index 0000000..79da143 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/review/v1/controller/ReviewControllerTest.kt @@ -0,0 +1,583 @@ +package com.devooks.backend.review.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.domain.AccessToken +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.request.CreateEbookRequest +import com.devooks.backend.ebook.v1.dto.request.SaveDescriptionImagesRequest +import com.devooks.backend.ebook.v1.dto.request.SaveMainImageRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookResponse +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse +import com.devooks.backend.ebook.v1.repository.EbookImageRepository +import com.devooks.backend.ebook.v1.repository.EbookRepository +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.notification.v1.adapter.out.persistence.NotificationRepository +import com.devooks.backend.notification.v1.domain.NotificationType +import com.devooks.backend.pdf.v1.dto.PdfDto +import com.devooks.backend.pdf.v1.dto.UploadPdfResponse +import com.devooks.backend.pdf.v1.repository.PdfRepository +import com.devooks.backend.pdf.v1.repository.PreviewImageRepository +import com.devooks.backend.review.v1.dto.CreateReviewRequest +import com.devooks.backend.review.v1.dto.CreateReviewResponse +import com.devooks.backend.review.v1.dto.GetReviewsResponse +import com.devooks.backend.review.v1.dto.ModifyReviewRequest +import com.devooks.backend.review.v1.dto.ModifyReviewResponse +import com.devooks.backend.review.v1.dto.ReviewDto +import com.devooks.backend.review.v1.repository.ReviewRepository +import com.devooks.backend.transaciton.v1.domain.PaymentMethod +import com.devooks.backend.transaciton.v1.dto.CreateTransactionRequest +import com.devooks.backend.transaciton.v1.dto.CreateTransactionResponse +import com.devooks.backend.transaciton.v1.repository.TransactionRepository +import io.netty.handler.codec.http.HttpResponseStatus.CONFLICT +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.FileSystemResource +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.http.MediaType.MULTIPART_FORM_DATA +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters + +@IntegrationTest +internal class ReviewControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val pdfRepository: PdfRepository, + private val previewImageRepository: PreviewImageRepository, + private val ebookRepository: EbookRepository, + private val ebookImageRepository: EbookImageRepository, + private val transactionRepository: TransactionRepository, + private val reviewRepository: ReviewRepository, + private val notificationRepository: NotificationRepository, +) { + lateinit var expectedMember1: Member + lateinit var expectedMember2: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember1 = memberRepository.save(MemberEntity(nickname = "nickname1")).toDomain() + expectedMember2 = memberRepository.save(MemberEntity(nickname = "nickname2")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + reviewRepository.deleteAll() + transactionRepository.deleteAll() + ebookImageRepository.deleteAll() + previewImageRepository.deleteAll() + ebookRepository.deleteAll() + pdfRepository.deleteAll() + memberRepository.deleteAll() + notificationRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `리뷰를 작성할 수 있다`(): Unit = runBlocking { + val (createEbookResponse, accessToken) = postCreateEbookAndCreateTransaction() + + val createReviewRequest = + CreateReviewRequest( + ebookId = createEbookResponse.ebook.id.toString(), + rating = "5", + content = "content" + ) + val createReviewResponse = webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val review = createReviewResponse.review + assertThat(review.rating.toString()).isEqualTo(createReviewRequest.rating) + assertThat(review.content).isEqualTo(createReviewRequest.content) + assertThat(review.ebookId.toString()).isEqualTo(createReviewRequest.ebookId) + assertThat(review.writerMemberId).isEqualTo(expectedMember2.id) + + val notification = notificationRepository.findAll().toList()[0] + assertThat(notification.type).isEqualTo(NotificationType.REVIEW) + assertThat(notification.receiverId).isEqualTo(createEbookResponse.ebook.sellingMemberId) + assertThat(notification.note["ebookId"]).isEqualTo(createEbookResponse.ebook.id.toString()) + assertThat(notification.note["reviewId"]).isEqualTo(review.id.toString()) + assertThat(notification.note["ebookTitle"]).isEqualTo(createEbookResponse.ebook.title) + assertThat(notification.note["reviewerName"]).isEqualTo(expectedMember2.nickname) + } + + @Test + fun `전자책에 대한 리뷰를 조회할 수 있다`(): Unit = runBlocking { + val createReviewResponse = postCreateReview() + + val getReviewsResponse = webTestClient + .get() + .uri("/api/v1/reviews?page=1&count=10&ebookId=${createReviewResponse.ebookId}") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val review = getReviewsResponse.reviews[0] + assertThat(review.id).isEqualTo(createReviewResponse.id) + assertThat(review.ebookId).isEqualTo(createReviewResponse.ebookId) + assertThat(review.content).isEqualTo(createReviewResponse.content) + assertThat(review.rating).isEqualTo(createReviewResponse.rating) + assertThat(review.writerMemberId).isEqualTo(createReviewResponse.writerMemberId) + assertThat(review.writtenDate).isEqualTo(createReviewResponse.writtenDate) + assertThat(review.modifiedDate).isEqualTo(createReviewResponse.modifiedDate) + } + + @Test + fun `회원에 대한 리뷰를 조회할 수 있다`(): Unit = runBlocking { + val createReviewResponse = postCreateReview() + + val getReviewsResponse = webTestClient + .get() + .uri("/api/v1/reviews?page=1&count=10&memberId=${expectedMember1.id}") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val review = getReviewsResponse.reviews[0] + assertThat(review.id).isEqualTo(createReviewResponse.id) + assertThat(review.ebookId).isEqualTo(createReviewResponse.ebookId) + assertThat(review.content).isEqualTo(createReviewResponse.content) + assertThat(review.rating).isEqualTo(createReviewResponse.rating) + assertThat(review.writerMemberId).isEqualTo(createReviewResponse.writerMemberId) + assertThat(review.writtenDate).isEqualTo(createReviewResponse.writtenDate) + assertThat(review.modifiedDate).isEqualTo(createReviewResponse.modifiedDate) + } + + @Test + fun `리뷰를 수정할 수 있다`(): Unit = runBlocking { + val createReviewResponse = postCreateReview() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + val request = ModifyReviewRequest( + rating = "5", + content = "content" + ) + + val modifyReviewsResponse = webTestClient + .patch() + .uri("/api/v1/reviews/${createReviewResponse.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val review = modifyReviewsResponse.review + assertThat(review.content).isEqualTo(request.content) + assertThat(review.rating.toString()).isEqualTo(request.rating) + } + + @Test + fun `리뷰를 삭제할 수 있다`(): Unit = runBlocking { + val createReviewResponse = postCreateReview() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + webTestClient + .delete() + .uri("/api/v1/reviews/${createReviewResponse.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + } + + @Test + fun `리뷰 삭제시 자신이 작성한 리뷰가 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val createReviewResponse = postCreateReview() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + + webTestClient + .delete() + .uri("/api/v1/reviews/${createReviewResponse.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `리뷰 수정시 리뷰가 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + postCreateReview() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + val request = ModifyReviewRequest( + rating = "5", + content = "content" + ) + + webTestClient + .patch() + .uri("/api/v1/reviews/${UUID.randomUUID()}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `리뷰 수정시 리뷰가 자신이 작성한 것이 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val createReviewResponse = postCreateReview() + val accessToken = tokenService.createTokenGroup(expectedMember1).accessToken + val request = ModifyReviewRequest( + rating = "5", + content = "content" + ) + + webTestClient + .patch() + .uri("/api/v1/reviews/${createReviewResponse.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `리뷰를 작성시 평점이 0~5가 아닐경우 예외가 발생한다`(): Unit = runBlocking { + val (createEbookResponse, accessToken) = postCreateEbookAndCreateTransaction() + + val createReviewRequest = + CreateReviewRequest( + ebookId = createEbookResponse.ebook.id.toString(), + rating = "6", + content = "content" + ) + webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isBadRequest + } + + @Test + fun `리뷰를 작성시 평점이 숫자가 아닐경우 예외가 발생한다`(): Unit = runBlocking { + val (createEbookResponse, accessToken) = postCreateEbookAndCreateTransaction() + + val createReviewRequest = + CreateReviewRequest( + ebookId = createEbookResponse.ebook.id.toString(), + rating = "a", + content = "content" + ) + webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isBadRequest + } + + @Test + fun `구매하지 않은 책에 리뷰를 작성할 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, createEbookResponse) = postCreateEbook() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + val createReviewRequest = + CreateReviewRequest( + ebookId = createEbookResponse.ebook.id.toString(), + rating = "5", + content = "content" + ) + webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `존재하지 않는 책에 리뷰를 작성할 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, accessToken) = postCreateEbookAndCreateTransaction() + + val createReviewRequest = + CreateReviewRequest( + ebookId = UUID.randomUUID().toString(), + rating = "5", + content = "content" + ) + webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `리뷰를 작성시 이미 리뷰가 존재할 경우 예외가 발생한다`(): Unit = runBlocking { + val (createEbookResponse, accessToken) = postCreateEbookAndCreateTransaction() + + val createReviewRequest = + CreateReviewRequest( + ebookId = createEbookResponse.ebook.id.toString(), + rating = "5", + content = "content" + ) + webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isOk + + webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isEqualTo(CONFLICT.code()) + } + + private suspend fun postCreateReview(): ReviewDto { + val (createEbookResponse, accessToken) = postCreateEbookAndCreateTransaction() + + val createReviewRequest = + CreateReviewRequest( + ebookId = createEbookResponse.ebook.id.toString(), + rating = "5", + content = "content" + ) + val createReviewResponse = webTestClient + .post() + .uri("/api/v1/reviews") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createReviewRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return createReviewResponse.review + } + + private suspend fun postCreateEbookAndCreateTransaction(): Pair { + val (_, createEbookResponse) = postCreateEbook() + val createTransactionRequest = CreateTransactionRequest( + ebookId = createEbookResponse.ebook.id.toString(), + paymentMethod = PaymentMethod.CREDIT_CARD.name, + price = createEbookResponse.ebook.price + ) + + val tokenGroup = tokenService.createTokenGroup(expectedMember2) + val accessToken = tokenGroup.accessToken + + webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(createEbookResponse, accessToken) + } + + suspend fun postCreateEbook(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + val descriptionImageList = postSaveDescriptionImages(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = descriptionImageList.map { it.id.toString() }, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + fun postSaveDescriptionImages( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): List { + val saveDescriptionImagesRequest = SaveDescriptionImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val descriptionImageList = webTestClient + .post() + .uri("/api/v1/ebooks/description-images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveDescriptionImagesRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .descriptionImageList + return descriptionImageList + } + + private fun postSaveMainImage( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): SaveMainImageResponse.MainImageDto { + val saveMainImageRequest = SaveMainImageRequest( + SaveMainImageRequest.MainImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + ) + ) + + val mainImage = webTestClient + .post() + .uri("/api/v1/ebooks/main-image") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveMainImageRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .mainImage + return mainImage + } + + suspend fun postUploadPdfFile(accessToken: String): PdfDto { + val pdfResource = File(javaClass.classLoader.getResource("valid_test.pdf")!!.path) + + val formData = LinkedMultiValueMap() + formData.add("pdf", FileSystemResource(pdfResource)) + + val pdf = webTestClient + .post() + .uri("/api/v1/pdfs") + .contentType(MULTIPART_FORM_DATA) + .header(AUTHORIZATION, "Bearer $accessToken") + .body(BodyInserters.fromMultipartData(formData)) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .pdf + return pdf + } +} diff --git a/src/test/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryControllerTest.kt b/src/test/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryControllerTest.kt new file mode 100644 index 0000000..00bb0a0 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryControllerTest.kt @@ -0,0 +1,355 @@ +package com.devooks.backend.service.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.domain.AccessToken +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.service.v1.domain.InquiryProcessingStatus +import com.devooks.backend.service.v1.dto.ServiceInquiryImageDto +import com.devooks.backend.service.v1.dto.request.CreateServiceInquiryRequest +import com.devooks.backend.service.v1.dto.request.ModifyServiceInquiryRequest +import com.devooks.backend.service.v1.dto.request.SaveServiceInquiryImagesRequest +import com.devooks.backend.service.v1.dto.response.CreateServiceInquiryResponse +import com.devooks.backend.service.v1.dto.response.GetServiceInquiriesResponse +import com.devooks.backend.service.v1.dto.response.ModifyServiceInquiryResponse +import com.devooks.backend.service.v1.dto.response.SaveServiceInquiryImagesResponse +import com.devooks.backend.service.v1.repository.ServiceInquiryImageRepository +import com.devooks.backend.service.v1.repository.ServiceInquiryRepository +import java.io.File +import java.nio.file.Files +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody + +@IntegrationTest +internal class ServiceInquiryControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val serviceInquiryImageRepository: ServiceInquiryImageRepository, + private val serviceInquiryRepository: ServiceInquiryRepository, +) { + lateinit var expectedMember1: Member + lateinit var expectedMember2: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember1 = memberRepository.save(MemberEntity(nickname = "nickname")).toDomain() + expectedMember2 = memberRepository.save(MemberEntity(nickname = "nickname2")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + serviceInquiryImageRepository.deleteAll() + serviceInquiryRepository.deleteAll() + memberRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `서비스 문의를 생성할 수 있다`(): Unit = runBlocking { + val (accessToken, imageList) = postSaveServiceInquiryImages() + + val createServiceInquiryRequest = CreateServiceInquiryRequest( + title = "title", + content = "content", + imageIdList = imageList.map { it.id.toString() } + ) + + val serviceInquiry = webTestClient + .post() + .uri("/api/v1/service-inquiries") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createServiceInquiryRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .serviceInquiry + + assertThat(serviceInquiry.title).isEqualTo(createServiceInquiryRequest.title) + assertThat(serviceInquiry.content).isEqualTo(createServiceInquiryRequest.content) + assertThat(serviceInquiry.writerMemberId).isEqualTo(expectedMember1.id) + assertThat(serviceInquiry.imageList.map { it.id }).containsAll(imageList.map { it.id }) + assertThat(serviceInquiry.inquiryProcessingStatus).isEqualTo(InquiryProcessingStatus.WAITING) + } + + @Test + fun `서비스 문의 생성시 이미지가 자신이 업로드한 경우가 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, imageList) = postSaveServiceInquiryImages() + val accessToken = tokenService.createTokenGroup(expectedMember2).accessToken + + val createServiceInquiryRequest = CreateServiceInquiryRequest( + title = "title", + content = "content", + imageIdList = imageList.map { it.id.toString() } + ) + + webTestClient + .post() + .uri("/api/v1/service-inquiries") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createServiceInquiryRequest) + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `서비스 문의를 조회할 수 있다`(): Unit = runBlocking { + val (accessToken, imageList) = postSaveServiceInquiryImages() + + val createServiceInquiryRequest = CreateServiceInquiryRequest( + title = "title", + content = "content", + imageIdList = imageList.map { it.id.toString() } + ) + + val serviceInquiry = webTestClient + .post() + .uri("/api/v1/service-inquiries") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createServiceInquiryRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .serviceInquiry + + val serviceInquiryView = webTestClient + .get() + .uri("/api/v1/service-inquiries?page=1&count=10") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .serviceInquiryList[0] + + assertThat(serviceInquiryView.id).isEqualTo(serviceInquiry.id) + assertThat(serviceInquiryView.title).isEqualTo(serviceInquiry.title) + assertThat(serviceInquiryView.imageList).containsAll(serviceInquiry.imageList) + assertThat(serviceInquiryView.content).isEqualTo(serviceInquiry.content) + assertThat(serviceInquiryView.inquiryProcessingStatus).isEqualTo(serviceInquiry.inquiryProcessingStatus) + assertThat(serviceInquiryView.createdDate).isEqualTo(serviceInquiry.createdDate) + assertThat(serviceInquiryView.modifiedDate).isEqualTo(serviceInquiry.modifiedDate) + assertThat(serviceInquiryView.writerMemberId).isEqualTo(serviceInquiry.writerMemberId) + } + + @Test + fun `서비스 문의를 수정할 수 있다`(): Unit = runBlocking { + val (accessToken, imageList1) = postSaveServiceInquiryImages() + + val createServiceInquiryRequest = CreateServiceInquiryRequest( + title = "title", + content = "content", + imageIdList = imageList1.map { it.id.toString() } + ) + + val createdServiceInquiry = webTestClient + .post() + .uri("/api/v1/service-inquiries") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createServiceInquiryRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .serviceInquiry + + val (_, imageList2) = postSaveServiceInquiryImages() + val modifyServiceInquiryRequest = ModifyServiceInquiryRequest( + serviceInquiry = ModifyServiceInquiryRequest.ServiceInquiry( + title = "title2", + content = "content2", + imageIdList = listOf(imageList1.map { it.id.toString() }.first(), imageList2.first().id.toString()) + ), + isChanged = ModifyServiceInquiryRequest.IsChanged( + title = true, + content = true, + imageIdList = true + ) + ) + + val updatedServiceInquiry = webTestClient + .patch() + .uri("/api/v1/service-inquiries/${createdServiceInquiry.id}") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(modifyServiceInquiryRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .serviceInquiry + + assertThat(updatedServiceInquiry.id).isEqualTo(createdServiceInquiry.id) + assertThat(updatedServiceInquiry.title).isEqualTo(modifyServiceInquiryRequest.serviceInquiry!!.title) + assertThat(updatedServiceInquiry.content).isEqualTo(modifyServiceInquiryRequest.serviceInquiry!!.content) + assertThat(updatedServiceInquiry.imageList.map { it.id.toString() }).containsAll(modifyServiceInquiryRequest.serviceInquiry!!.imageIdList) + assertThat(updatedServiceInquiry.writerMemberId).isEqualTo(createdServiceInquiry.writerMemberId) + assertThat(updatedServiceInquiry.inquiryProcessingStatus).isEqualTo(createdServiceInquiry.inquiryProcessingStatus) + } + + @Test + fun `서비스 문의 수정시 자신이 작성한 문의가 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val (accessToken1, imageList1) = postSaveServiceInquiryImages() + val accessToken2 = tokenService.createTokenGroup(expectedMember2).accessToken + + val createServiceInquiryRequest = CreateServiceInquiryRequest( + title = "title", + content = "content", + imageIdList = imageList1.map { it.id.toString() } + ) + + val createdServiceInquiry = webTestClient + .post() + .uri("/api/v1/service-inquiries") + .header(AUTHORIZATION, "Bearer $accessToken1") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createServiceInquiryRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .serviceInquiry + + val (_, imageList2) = postSaveServiceInquiryImages() + val modifyServiceInquiryRequest = ModifyServiceInquiryRequest( + serviceInquiry = ModifyServiceInquiryRequest.ServiceInquiry( + title = "title2", + content = "content2", + imageIdList = listOf(imageList1.map { it.id.toString() }.first(), imageList2.first().id.toString()) + ), + isChanged = ModifyServiceInquiryRequest.IsChanged( + title = true, + content = true, + imageIdList = true + ) + ) + + val updatedServiceInquiry = webTestClient + .patch() + .uri("/api/v1/service-inquiries/${createdServiceInquiry.id}") + .header(AUTHORIZATION, "Bearer $accessToken2") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(modifyServiceInquiryRequest) + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `서비스 문의 수정시 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val (accessToken, _) = postSaveServiceInquiryImages() + + val modifyServiceInquiryRequest = ModifyServiceInquiryRequest( + serviceInquiry = ModifyServiceInquiryRequest.ServiceInquiry( + title = "title2", + content = "content2", + ), + isChanged = ModifyServiceInquiryRequest.IsChanged( + title = true, + content = true, + ) + ) + + webTestClient + .patch() + .uri("/api/v1/service-inquiries/${UUID.randomUUID()}") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(modifyServiceInquiryRequest) + .exchange() + .expectStatus().isNotFound + } + + private suspend fun postSaveServiceInquiryImages(): Pair> { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val serviceInquiryImagesRequest = SaveServiceInquiryImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val imageList = webTestClient + .post() + .uri("/api/v1/service-inquiries/images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(serviceInquiryImagesRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .imageList + return Pair(accessToken, imageList) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryImagesControllerTest.kt b/src/test/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryImagesControllerTest.kt new file mode 100644 index 0000000..a267927 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/service/v1/controller/ServiceInquiryImagesControllerTest.kt @@ -0,0 +1,113 @@ +package com.devooks.backend.service.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.service.v1.dto.request.SaveServiceInquiryImagesRequest +import com.devooks.backend.service.v1.dto.response.SaveServiceInquiryImagesResponse +import com.devooks.backend.service.v1.repository.ServiceInquiryImageRepository +import java.io.File +import java.nio.file.Files +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody + +@IntegrationTest +internal class ServiceInquiryImagesControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val serviceInquiryImageRepository: ServiceInquiryImageRepository, +) { + lateinit var expectedMember: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember = memberRepository.save(MemberEntity(nickname = "nickname")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + serviceInquiryImageRepository.deleteAll() + memberRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `서비스 문의 사진 목록을 저장할 수 있다`(): Unit = runBlocking { + val tokenGroup = tokenService.createTokenGroup(expectedMember) + val accessToken = tokenGroup.accessToken + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val request = SaveServiceInquiryImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val imageList = webTestClient + .post() + .uri("/api/v1/service-inquiries/images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(request) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .imageList + + imageList.forEachIndexed { index, image -> + val expected = request.imageList!![index] + assertThat(image.order).isEqualTo(expected.order) + assertThat(File(image.imagePath).exists()).isTrue() + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/transaciton/v1/controller/TransactionControllerTest.kt b/src/test/kotlin/com/devooks/backend/transaciton/v1/controller/TransactionControllerTest.kt new file mode 100644 index 0000000..9be8992 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/transaciton/v1/controller/TransactionControllerTest.kt @@ -0,0 +1,450 @@ +package com.devooks.backend.transaciton.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.domain.AccessToken +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.request.CreateEbookRequest +import com.devooks.backend.ebook.v1.dto.request.SaveDescriptionImagesRequest +import com.devooks.backend.ebook.v1.dto.request.SaveMainImageRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookResponse +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse +import com.devooks.backend.ebook.v1.repository.EbookImageRepository +import com.devooks.backend.ebook.v1.repository.EbookRepository +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.pdf.v1.dto.PdfDto +import com.devooks.backend.pdf.v1.dto.UploadPdfResponse +import com.devooks.backend.pdf.v1.repository.PdfRepository +import com.devooks.backend.pdf.v1.repository.PreviewImageRepository +import com.devooks.backend.transaciton.v1.domain.PaymentMethod +import com.devooks.backend.transaciton.v1.dto.CreateTransactionRequest +import com.devooks.backend.transaciton.v1.dto.CreateTransactionResponse +import com.devooks.backend.transaciton.v1.dto.GetBuyHistoriesResponse +import com.devooks.backend.transaciton.v1.dto.GetSellHistoriesResponse +import com.devooks.backend.transaciton.v1.repository.TransactionRepository +import io.netty.handler.codec.http.HttpResponseStatus.CONFLICT +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.FileSystemResource +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.http.MediaType.MULTIPART_FORM_DATA +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters + +@IntegrationTest +internal class TransactionControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val pdfRepository: PdfRepository, + private val previewImageRepository: PreviewImageRepository, + private val ebookRepository: EbookRepository, + private val ebookImageRepository: EbookImageRepository, + private val transactionRepository: TransactionRepository, +) { + lateinit var expectedMember1: Member + lateinit var expectedMember2: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember1 = memberRepository.save(MemberEntity(nickname = "nickname1")).toDomain() + expectedMember2 = memberRepository.save(MemberEntity(nickname = "nickname2")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + transactionRepository.deleteAll() + ebookImageRepository.deleteAll() + previewImageRepository.deleteAll() + ebookRepository.deleteAll() + pdfRepository.deleteAll() + memberRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `전자책을 구매할 수 있다`(): Unit = runBlocking { + val (_, createEbookResponse) = postCreateEbook() + val createTransactionRequest = CreateTransactionRequest( + ebookId = createEbookResponse.ebook.id.toString(), + paymentMethod = PaymentMethod.CREDIT_CARD.name, + price = createEbookResponse.ebook.price + ) + + val tokenGroup = tokenService.createTokenGroup(expectedMember2) + val accessToken = tokenGroup.accessToken + + val createTransactionResponse = webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + assertThat(createTransactionResponse.ebookId).isEqualTo(createEbookResponse.ebook.id) + assertThat(createTransactionResponse.price).isEqualTo(createEbookResponse.ebook.price) + assertThat(createTransactionResponse.paymentMethod).isEqualTo(PaymentMethod.CREDIT_CARD) + } + + @Test + fun `전자책을 구매할 때 전자책이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val createTransactionRequest = CreateTransactionRequest( + ebookId = UUID.randomUUID().toString(), + paymentMethod = PaymentMethod.CREDIT_CARD.name, + price = 1000 + ) + + val tokenGroup = tokenService.createTokenGroup(expectedMember2) + val accessToken = tokenGroup.accessToken + + webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `전자책을 구매할 때 가격이 다를 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, createEbookResponse) = postCreateEbook() + val createTransactionRequest = CreateTransactionRequest( + ebookId = createEbookResponse.ebook.id.toString(), + paymentMethod = PaymentMethod.CREDIT_CARD.name, + price = 1000 + ) + + val tokenGroup = tokenService.createTokenGroup(expectedMember2) + val accessToken = tokenGroup.accessToken + + webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isBadRequest + } + + @Test + fun `전자책을 구매할 때 자신의 책을 구매할 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, createEbookResponse) = postCreateEbook() + val createTransactionRequest = CreateTransactionRequest( + ebookId = createEbookResponse.ebook.id.toString(), + paymentMethod = PaymentMethod.CREDIT_CARD.name, + price = createEbookResponse.ebook.price + ) + + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + + webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `전자책을 구매할 때 이미 구매했을 경우 예외가 발생한다`(): Unit = runBlocking { + val (_, createEbookResponse) = postCreateEbook() + val createTransactionRequest = CreateTransactionRequest( + ebookId = createEbookResponse.ebook.id.toString(), + paymentMethod = PaymentMethod.CREDIT_CARD.name, + price = createEbookResponse.ebook.price + ) + + val tokenGroup = tokenService.createTokenGroup(expectedMember2) + val accessToken = tokenGroup.accessToken + + webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isOk + + webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isEqualTo(CONFLICT.code()) + } + + @Test + fun `구매한 거래를 조회할 수 있다`(): Unit = runBlocking { + val (createEbookResponse, accessToken, createTransactionResponse) = postCreateTransaction() + + val getBuyHistoriesResponse = webTestClient + .get() + .uri("/api/v1/transactions/buy-histories?page=1&count=10") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val transaction = getBuyHistoriesResponse.transactionList[0] + assertThat(transaction.id).isEqualTo(createTransactionResponse.transactionId) + assertThat(transaction.transactionDate).isEqualTo(createTransactionResponse.transactionDate) + assertThat(transaction.price).isEqualTo(createEbookResponse.ebook.price) + assertThat(transaction.ebookId).isEqualTo(createEbookResponse.ebook.id) + } + + @Test + fun `구매한 거래를 책 이름으로 검색할 수 있다`(): Unit = runBlocking { + val (createEbookResponse, accessToken, createTransactionResponse) = postCreateTransaction() + + val getBuyHistoriesResponse = webTestClient + .get() + .uri("/api/v1/transactions/buy-histories?page=1&count=10&ebookTitle=${createEbookResponse.ebook.title.first()}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val transaction = getBuyHistoriesResponse.transactionList[0] + assertThat(transaction.id).isEqualTo(createTransactionResponse.transactionId) + assertThat(transaction.transactionDate).isEqualTo(createTransactionResponse.transactionDate) + assertThat(transaction.price).isEqualTo(createEbookResponse.ebook.price) + assertThat(transaction.ebookId).isEqualTo(createEbookResponse.ebook.id) + } + + @Test + fun `판매한 거래를 조회할 수 있다`(): Unit = runBlocking { + val (createEbookResponse, _, createTransactionResponse) = postCreateTransaction() + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + + val getSellHistoriesResponse = webTestClient + .get() + .uri("/api/v1/transactions/sell-histories?page=1&count=10") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + + val transaction = getSellHistoriesResponse.transactionList[0] + assertThat(transaction.id).isEqualTo(createTransactionResponse.transactionId) + assertThat(transaction.transactionDate).isEqualTo(createTransactionResponse.transactionDate) + assertThat(transaction.price).isEqualTo(createEbookResponse.ebook.price) + assertThat(transaction.ebookId).isEqualTo(createEbookResponse.ebook.id) + } + + private suspend fun TransactionControllerTest.postCreateTransaction(): Triple { + val (_, createEbookResponse) = postCreateEbook() + val createTransactionRequest = CreateTransactionRequest( + ebookId = createEbookResponse.ebook.id.toString(), + paymentMethod = PaymentMethod.CREDIT_CARD.name, + price = createEbookResponse.ebook.price + ) + + val tokenGroup = tokenService.createTokenGroup(expectedMember2) + val accessToken = tokenGroup.accessToken + + val createTransactionResponse = webTestClient + .post() + .uri("/api/v1/transactions") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(createTransactionRequest) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Triple(createEbookResponse, accessToken, createTransactionResponse) + } + + suspend fun postCreateEbook(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember1) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + val descriptionImageList = postSaveDescriptionImages(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = descriptionImageList.map { it.id.toString() }, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(request, response) + } + + fun postSaveDescriptionImages( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): List { + val saveDescriptionImagesRequest = SaveDescriptionImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val descriptionImageList = webTestClient + .post() + .uri("/api/v1/ebooks/description-images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveDescriptionImagesRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .descriptionImageList + return descriptionImageList + } + + private fun postSaveMainImage( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): SaveMainImageResponse.MainImageDto { + val saveMainImageRequest = SaveMainImageRequest( + SaveMainImageRequest.MainImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + ) + ) + + val mainImage = webTestClient + .post() + .uri("/api/v1/ebooks/main-image") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveMainImageRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .mainImage + return mainImage + } + + + suspend fun postUploadPdfFile(accessToken: String): PdfDto { + val pdfResource = File(javaClass.classLoader.getResource("valid_test.pdf")!!.path) + + val formData = LinkedMultiValueMap() + formData.add("pdf", FileSystemResource(pdfResource)) + + val pdf = webTestClient + .post() + .uri("/api/v1/pdfs") + .contentType(MULTIPART_FORM_DATA) + .header(AUTHORIZATION, "Bearer $accessToken") + .body(BodyInserters.fromMultipartData(formData)) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .pdf + return pdf + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/devooks/backend/wishlist/v1/controller/WishlistControllerTest.kt b/src/test/kotlin/com/devooks/backend/wishlist/v1/controller/WishlistControllerTest.kt new file mode 100644 index 0000000..fe22e27 --- /dev/null +++ b/src/test/kotlin/com/devooks/backend/wishlist/v1/controller/WishlistControllerTest.kt @@ -0,0 +1,406 @@ +package com.devooks.backend.wishlist.v1.controller + +import com.devooks.backend.BackendApplication.Companion.STATIC_ROOT_PATH +import com.devooks.backend.BackendApplication.Companion.createDirectories +import com.devooks.backend.auth.v1.domain.AccessToken +import com.devooks.backend.auth.v1.service.TokenService +import com.devooks.backend.common.dto.ImageDto +import com.devooks.backend.config.IntegrationTest +import com.devooks.backend.ebook.v1.dto.DescriptionImageDto +import com.devooks.backend.ebook.v1.dto.request.CreateEbookRequest +import com.devooks.backend.ebook.v1.dto.request.SaveDescriptionImagesRequest +import com.devooks.backend.ebook.v1.dto.request.SaveMainImageRequest +import com.devooks.backend.ebook.v1.dto.response.CreateEbookResponse +import com.devooks.backend.ebook.v1.dto.response.SaveDescriptionImagesResponse +import com.devooks.backend.ebook.v1.dto.response.SaveMainImageResponse +import com.devooks.backend.ebook.v1.repository.EbookImageRepository +import com.devooks.backend.ebook.v1.repository.EbookRepository +import com.devooks.backend.member.v1.domain.Member +import com.devooks.backend.member.v1.domain.Member.Companion.toDomain +import com.devooks.backend.member.v1.entity.MemberEntity +import com.devooks.backend.member.v1.repository.MemberRepository +import com.devooks.backend.pdf.v1.dto.PdfDto +import com.devooks.backend.pdf.v1.dto.UploadPdfResponse +import com.devooks.backend.pdf.v1.repository.PdfRepository +import com.devooks.backend.pdf.v1.repository.PreviewImageRepository +import com.devooks.backend.wishlist.v1.dto.CreateWishlistRequest +import com.devooks.backend.wishlist.v1.dto.CreateWishlistResponse +import com.devooks.backend.wishlist.v1.dto.GetWishlistResponse +import com.devooks.backend.wishlist.v1.repository.WishlistRepository +import io.netty.handler.codec.http.HttpResponseStatus.CONFLICT +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.FileSystemResource +import org.springframework.http.HttpHeaders.AUTHORIZATION +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.http.MediaType.MULTIPART_FORM_DATA +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.BodyInserters + +@IntegrationTest +internal class WishlistControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val pdfRepository: PdfRepository, + private val previewImageRepository: PreviewImageRepository, + private val ebookImageRepository: EbookImageRepository, + private val ebookRepository: EbookRepository, + private val wishlistRepository: WishlistRepository, +) { + lateinit var expectedMember: Member + + @BeforeEach + fun setup(): Unit = runBlocking { + expectedMember = memberRepository.save(MemberEntity(nickname = "nickname")).toDomain() + } + + @AfterEach + fun tearDown(): Unit = runBlocking { + wishlistRepository.deleteAll() + ebookImageRepository.deleteAll() + previewImageRepository.deleteAll() + ebookRepository.deleteAll() + pdfRepository.deleteAll() + memberRepository.deleteAll() + } + + companion object { + @JvmStatic + @BeforeAll + fun setUpAll(): Unit = runBlocking { + createDirectories() + } + + @JvmStatic + @AfterAll + fun tearDownAll(): Unit = runBlocking { + File(STATIC_ROOT_PATH).deleteRecursively() + } + } + + @Test + fun `전자책을 찜 할 수 있다`(): Unit = runBlocking { + val (accessToken, createEbookResponse) = postCreateEbook() + + val response = postCreateWishlist(createEbookResponse, accessToken) + + assertThat(response.ebookId).isEqualTo(createEbookResponse.ebook.id) + assertThat(response.memberId).isEqualTo(expectedMember.id) + } + + @Test + fun `존재하지 않는 전자책을 찜할 경우 예외가 발생한다`(): Unit = runBlocking { + val accessToken = tokenService.createTokenGroup(expectedMember).accessToken + val request = CreateWishlistRequest( + ebookId = UUID.randomUUID().toString() + ) + + webTestClient + .post() + .uri("/api/v1/wishlist") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `찜이 이미 존재할 경우 예외가 발생한다`(): Unit = runBlocking { + val (accessToken, createEbookResponse) = postCreateEbook() + + val request = CreateWishlistRequest( + ebookId = createEbookResponse.ebook.id.toString() + ) + + webTestClient + .post() + .uri("/api/v1/wishlist") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + + webTestClient + .post() + .uri("/api/v1/wishlist") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isEqualTo(CONFLICT.code()) + } + + @Test + fun `찜 목록을 조회할 수 있다`(): Unit = runBlocking { + val (accessToken, createEbookResponse) = postCreateEbook() + + val response = postCreateWishlist(createEbookResponse, accessToken) + + val wishlist = webTestClient + .get() + .uri("/api/v1/wishlist?page=1&count=10") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .wishlist + + assertThat(wishlist[0].id).isEqualTo(response.wishlistId) + assertThat(wishlist[0].ebookId).isEqualTo(response.ebookId) + assertThat(wishlist[0].memberId).isEqualTo(response.memberId) + } + + @Test + fun `찜 목록을 카테고리로 조회할 수 있다`(): Unit = runBlocking { + val (accessToken, createEbookResponse) = postCreateEbook() + + val response = postCreateWishlist(createEbookResponse, accessToken) + + val wishlist = webTestClient + .get() + .uri("/api/v1/wishlist?page=1&count=10&" + + "categoryIds=${createEbookResponse.ebook.relatedCategoryNameList[0].id}&" + + "categoryIds=${UUID.randomUUID()}") + .header(AUTHORIZATION, "Bearer $accessToken") + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .wishlist + + assertThat(wishlist[0].id).isEqualTo(response.wishlistId) + assertThat(wishlist[0].ebookId).isEqualTo(response.ebookId) + assertThat(wishlist[0].memberId).isEqualTo(response.memberId) + } + + @Test + fun `찜을 삭제할 수 있다`(): Unit = runBlocking { + val (accessToken, createEbookResponse) = postCreateEbook() + + val response = postCreateWishlist(createEbookResponse, accessToken) + + webTestClient + .delete() + .uri("/api/v1/wishlist/${response.wishlistId}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + + assertThat(wishlistRepository.count()).isZero() + } + + @Test + fun `찜 삭제시 자신의 찜이 아닐 경우 예외가 발생한다`(): Unit = runBlocking { + val (accessToken, createEbookResponse) = postCreateEbook() + + val response = postCreateWishlist(createEbookResponse, accessToken) + + webTestClient + .delete() + .uri("/api/v1/wishlist/${response.wishlistId}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer ${UUID.randomUUID()}") + .exchange() + .expectStatus().isForbidden + } + + @Test + fun `찜 삭제시 찜이 존재하지 않을 경우 예외가 발생한다`(): Unit = runBlocking { + val (accessToken, _) = postCreateEbook() + + webTestClient + .delete() + .uri("/api/v1/wishlist/${UUID.randomUUID()}") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isNotFound + } + + @Test + fun `찜 삭제시 잘못된 찜 식별자일 경우 예외가 발생한다`(): Unit = runBlocking { + val accessToken = tokenService.createTokenGroup(expectedMember).accessToken + + webTestClient + .delete() + .uri("/api/v1/wishlist/ ") + .accept(APPLICATION_JSON) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isBadRequest + } + + private fun postCreateWishlist( + createEbookResponse: CreateEbookResponse, + accessToken: AccessToken, + ): CreateWishlistResponse { + val request = CreateWishlistRequest( + ebookId = createEbookResponse.ebook.id.toString() + ) + + val response = webTestClient + .post() + .uri("/api/v1/wishlist") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return response + } + + suspend fun postCreateEbook(): Pair { + val tokenGroup = tokenService.createTokenGroup(expectedMember) + val accessToken = tokenGroup.accessToken + val pdf = postUploadPdfFile(accessToken) + val imagePath = Path(javaClass.classLoader.getResource("test.jpg")!!.path) + val imageBytes = Files.readAllBytes(imagePath) + val imageBase64Raw = Base64.getEncoder().encodeToString(imageBytes) + + val mainImage = postSaveMainImage(imageBase64Raw, imagePath, accessToken) + val descriptionImageList = postSaveDescriptionImages(imageBase64Raw, imagePath, accessToken) + + val request = CreateEbookRequest( + pdfId = pdf.id.toString(), + title = "title", + relatedCategoryNameList = listOf("category"), + mainImageId = mainImage.id.toString(), + descriptionImageIdList = descriptionImageList.map { it.id.toString() }, + 10000, + "introduction", + "tableOfContent" + ) + val response = webTestClient + .post() + .uri("/api/v1/ebooks") + .header(AUTHORIZATION, "Bearer $accessToken") + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + return Pair(accessToken, response) + } + + fun postSaveDescriptionImages( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): List { + val saveDescriptionImagesRequest = SaveDescriptionImagesRequest( + imageList = listOf( + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 1 + ), + ImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + 2 + ), + ) + ) + + val descriptionImageList = webTestClient + .post() + .uri("/api/v1/ebooks/description-images") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveDescriptionImagesRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .descriptionImageList + return descriptionImageList + } + + private fun postSaveMainImage( + imageBase64Raw: String?, + imagePath: Path, + accessToken: AccessToken, + ): SaveMainImageResponse.MainImageDto { + val saveMainImageRequest = SaveMainImageRequest( + SaveMainImageRequest.MainImageDto( + imageBase64Raw, + imagePath.extension, + imagePath.fileSize(), + ) + ) + + val mainImage = webTestClient + .post() + .uri("/api/v1/ebooks/main-image") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .bodyValue(saveMainImageRequest) + .header(AUTHORIZATION, "Bearer $accessToken") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .mainImage + return mainImage + } + + suspend fun postUploadPdfFile(accessToken: String): PdfDto { + val pdfResource = File(javaClass.classLoader.getResource("valid_test.pdf")!!.path) + + val formData = LinkedMultiValueMap() + formData.add("pdf", FileSystemResource(pdfResource)) + + val pdf = webTestClient + .post() + .uri("/api/v1/pdfs") + .contentType(MULTIPART_FORM_DATA) + .header(AUTHORIZATION, "Bearer $accessToken") + .body(BodyInserters.fromMultipartData(formData)) + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() + .responseBody!! + .pdf + return pdf + } +} \ No newline at end of file diff --git a/src/test/resources/invalid_test1.pdf b/src/test/resources/invalid_test1.pdf new file mode 100644 index 0000000..e69de29 diff --git a/src/test/resources/invalid_test2.pdf b/src/test/resources/invalid_test2.pdf new file mode 100644 index 0000000..a12456d Binary files /dev/null and b/src/test/resources/invalid_test2.pdf differ diff --git a/src/test/resources/test.jpg b/src/test/resources/test.jpg new file mode 100644 index 0000000..438613e Binary files /dev/null and b/src/test/resources/test.jpg differ diff --git a/src/test/resources/valid_test.pdf b/src/test/resources/valid_test.pdf new file mode 100644 index 0000000..6a6685e Binary files /dev/null and b/src/test/resources/valid_test.pdf differ