diff --git a/.github/workflows/cicd-dev.yml b/.github/workflows/cicd-dev.yml index 988f2d60..465322c5 100644 --- a/.github/workflows/cicd-dev.yml +++ b/.github/workflows/cicd-dev.yml @@ -50,6 +50,15 @@ jobs: spring.datasource.url: ${{ secrets.DB_URL }} spring.datasource.username: ${{ secrets.DB_USER }} spring.datasource.password: ${{ secrets.DB_PW }} + spring.mail.username: ${{ secrets.SMTP_GOOGLE_EMAIL }} + spring.mail.password: ${{ secrets.SMTP_PASSWORD }} + spring.data.redis.host: ${{ secrets.REDIS_DEV_HOST }} + spring.data.redis.password: ${{ secrets.REDIS_DEV_PASSWORD }} + constants.host-url: ${{ secrets.DEV_HOST_URL }} + aws.credentials.access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws.credentials.secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws.s3.bucket.name: ${{ secrets.AWS_S3_BUCKET_NAME }} + aws.s3.bucket.url: ${{ secrets.AWS_S3_BUCKET_URL }} - name: Set application-dev yml file (batch) uses: microsoft/variable-substitution@v1 @@ -61,8 +70,6 @@ jobs: spring.datasource.password: ${{ secrets.DB_PW }} external.ecolife-api.path: ${{ secrets.ECOLIFE_PATH }} external.ecolife-api.service-key: ${{ secrets.ECOLIFE_KEY }} - spring.mail.username: ${{ secrets.SMTP_GOOGLE_EMAIL }} - spring.mail.password: ${{ secrets.SMTP_PASSWORD }} - name: Grant execute permission And Build with Gradle (api) working-directory: ./module-api @@ -113,6 +120,7 @@ jobs: mkdir -p deploy cd deploy echo "${{ secrets.DB_INIT_SQL }}" > init.sql + echo "${{ secrets.DEV_REDIS_CONF }}" > redis.conf echo "${{ secrets.DOCKER_COMPOSE }}" > docker-compose.yml docker-compose pull docker-compose down diff --git a/.github/workflows/cicd-prod.yml b/.github/workflows/cicd-prod.yml index 682697a9..c56ed832 100644 --- a/.github/workflows/cicd-prod.yml +++ b/.github/workflows/cicd-prod.yml @@ -45,6 +45,13 @@ jobs: spring.datasource.password: ${{ secrets.DB_PW }} spring.mail.username: ${{ secrets.SMTP_GOOGLE_EMAIL }} spring.mail.password: ${{ secrets.SMTP_PASSWORD }} + spring.data.redis.host: ${{ secrets.REDIS_PROD_HOST }} + spring.data.redis.password: ${{ secrets.REDIS_PROD_PASSWORD }} + constants.host-url: ${{ secrets.PROD_HOST_URL }} + aws.credentials.access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws.credentials.secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws.s3.bucket.name: ${{ secrets.AWS_S3_BUCKET_NAME }} + aws.s3.bucket.url: ${{ secrets.AWS_S3_BUCKET_URL }} - name: Grant execute permission And Build with Gradle (api) working-directory: ./module-api @@ -78,7 +85,7 @@ jobs: with: host: ${{ secrets.AWS_EC2_HOST }} username: ${{ secrets.AWS_USERNAME }} - key: ${{ secrets.AWS_KEY}} + key: ${{ secrets.AWS_KEY }} script: | # Install Docker and Docker Compose sudo dnf update @@ -91,6 +98,7 @@ jobs: # Docker Compose mkdir -p deploy cd deploy + echo "${{ secrets.PROD_REDIS_CONF }}" > redis.conf echo "${{ secrets.DOCKER_COMPOSE_AWS }}" > docker-compose.yml docker-compose pull diff --git a/module-admin/build.gradle b/module-admin/build.gradle new file mode 100644 index 00000000..34d37f24 --- /dev/null +++ b/module-admin/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com.kernel360' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':module-common') + implementation project(':module-domain') + + implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'org.postgresql:postgresql' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +tasks.named('test') { + useJUnitPlatform() +} + +tasks.register("prepareKotlinBuildScriptModel") {} \ No newline at end of file diff --git a/module-admin/gradle/wrapper/gradle-wrapper.jar b/module-admin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..d64cd491 Binary files /dev/null and b/module-admin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/module-admin/gradle/wrapper/gradle-wrapper.properties b/module-admin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..1af9e093 --- /dev/null +++ b/module-admin/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/module-admin/gradlew b/module-admin/gradlew new file mode 100755 index 00000000..1aa94a42 --- /dev/null +++ b/module-admin/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/module-admin/gradlew.bat b/module-admin/gradlew.bat new file mode 100644 index 00000000..93e3f59f --- /dev/null +++ b/module-admin/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/module-admin/src/main/java/com/kernel360/ModuleAdminApplication.java b/module-admin/src/main/java/com/kernel360/ModuleAdminApplication.java new file mode 100644 index 00000000..8b458e3f --- /dev/null +++ b/module-admin/src/main/java/com/kernel360/ModuleAdminApplication.java @@ -0,0 +1,13 @@ +package com.kernel360; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ModuleAdminApplication { + + public static void main(String[] args) { + SpringApplication.run(ModuleAdminApplication.class, args); + } + +} diff --git a/module-admin/src/main/resources/application-local.yml b/module-admin/src/main/resources/application-local.yml new file mode 100644 index 00000000..1a48c515 --- /dev/null +++ b/module-admin/src/main/resources/application-local.yml @@ -0,0 +1,37 @@ +spring: + datasource: + driver-class-name: org.postgresql.Driver + # local docker db + url: ENC(DPvTlKCZniOLZz5QHwB2I1p6FbeyEhWeo6DXSHL7kq8idfthgqkC/VJX78osBC5D/hzFwdXGDkxWUZ2euYPrAA==) + username: ENC(izKjVNL/2x4aTDQeLxQg4VU0v5kAKduTrnVq7LUa80U=) + password: ENC(VcPYkEOg9OzuwNw1UeYOgLqKsRkFoRUHwjRcxZzGgZQ=) + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + ddl-auto: validate + +jasypt: + encryptor: + algorithm: PBEWithMD5AndTripleDES + +logging: + file: + path: ./logs/admin/ + name: local-admin-log + logback: + rollingpolicy: + max-history: 14 + max-file-size: 50MB + total-size-cap: 5GB + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always \ No newline at end of file diff --git a/module-admin/src/main/resources/application.yml b/module-admin/src/main/resources/application.yml new file mode 100644 index 00000000..8f5d0af3 --- /dev/null +++ b/module-admin/src/main/resources/application.yml @@ -0,0 +1,7 @@ +spring: + profiles: + active: local + +server: + port: 8082 + diff --git a/module-admin/src/main/resources/logback-spring.xml b/module-admin/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..663d2464 --- /dev/null +++ b/module-admin/src/main/resources/logback-spring.xml @@ -0,0 +1,40 @@ + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + + + + + + ${FILE_LOG_PATTERN} + + + + ${LOG_PATH}/${LOG_FILE}.%d{yyyy-MM-dd}-%i.log + ${LOGBACK_ROLLINGPOLICY_MAX_HISTORY} + ${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE} + ${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP} + + + + + + + + + + + + + + \ No newline at end of file diff --git a/module-admin/src/test/java/com/kernel360/ModuleAdminApplicationTests.java b/module-admin/src/test/java/com/kernel360/ModuleAdminApplicationTests.java new file mode 100644 index 00000000..c9c52f46 --- /dev/null +++ b/module-admin/src/test/java/com/kernel360/ModuleAdminApplicationTests.java @@ -0,0 +1,13 @@ +package com.kernel360; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ModuleAdminApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/module-api/build.gradle b/module-api/build.gradle index fe0f0055..52194452 100644 --- a/module-api/build.gradle +++ b/module-api/build.gradle @@ -37,6 +37,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' //implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // jasypt implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5' diff --git a/module-api/src/docs/asciidoc/member-find-credential-api.adoc b/module-api/src/docs/asciidoc/member-find-credential-api.adoc new file mode 100644 index 00000000..e34554f6 --- /dev/null +++ b/module-api/src/docs/asciidoc/member-find-credential-api.adoc @@ -0,0 +1,60 @@ +== 아이디/비밀번호 찾기 API + +[[아이디-찾기]] +=== 아이디 찾기 이메일 발송 + +===== HTTP Request +include::{snippets}/member/find-memberId/request-fields.adoc[] + +===== HTTP Request 예시 +include::{snippets}/member/find-memberId/http-request.adoc[] + +==== Response + +include::{snippets}/member/find-memberId/response-fields.adoc[] + +===== HTTP Response 예시 + +include::{snippets}/member/find-memberId/http-response.adoc[] + +[[비밀번호-찾기]] +=== 비밀번호 재설정 이메일 발송 + +===== HTTP Request +include::{snippets}/member/find-password/request-fields.adoc[] + +===== HTTP Request 예시 +include::{snippets}/member/find-password/http-request.adoc[] + +==== Response +include::{snippets}/member/find-password/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/member/find-password/http-response.adoc[] + +=== 비밀번호 재설정 페이지 요청 +===== HTTP Request +include::{snippets}/member/get-reset-password/query-parameters.adoc[] + +===== HTTP Request 예시 +include::{snippets}/member/get-reset-password/http-request.adoc[] + +==== Response +include::{snippets}/member/get-reset-password/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/member/get-reset-password/http-response.adoc[] + + +=== 비밀번호 재설정 요청 +===== HTTP Request +include::{snippets}/member/post-reset-password/http-request.adoc[] + +===== HTTP Request 예시 +include::{snippets}/member/post-reset-password/http-request.adoc[] + +==== Response +include::{snippets}/member/post-reset-password/response-fields.adoc[] + +===== HTTP Response 예시 +include::{snippets}/member/post-reset-password/http-response.adoc[] diff --git a/module-api/src/main/java/com/kernel360/global/config/RedisConfig.java b/module-api/src/main/java/com/kernel360/global/config/RedisConfig.java new file mode 100644 index 00000000..05b821b9 --- /dev/null +++ b/module-api/src/main/java/com/kernel360/global/config/RedisConfig.java @@ -0,0 +1,41 @@ +package com.kernel360.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String host; + @Value("${spring.data.redis.port}") + private String port; + @Value("${spring.data.redis.password}") + private String password; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); + redisStandaloneConfiguration.setHostName(host); + redisStandaloneConfiguration.setPort(Integer.parseInt(port)); + redisStandaloneConfiguration.setPassword(password); + + return new LettuceConnectionFactory(redisStandaloneConfiguration); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setDefaultSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} diff --git a/module-api/src/main/java/com/kernel360/main/controller/Sort.java b/module-api/src/main/java/com/kernel360/main/controller/Sort.java index d81635e5..9287028a 100644 --- a/module-api/src/main/java/com/kernel360/main/controller/Sort.java +++ b/module-api/src/main/java/com/kernel360/main/controller/Sort.java @@ -25,9 +25,8 @@ Page sort(ProductService productService, Pageable pageable) { RECOMMENDATION_PRODUCT_ORDER("recommend-order") { @Override Page sort(ProductService productService, Pageable pageable) { -//Fixme :: 향후 Like Table 구현후, 정렬메소드 변경이 필요합니다.(임시로 violationProduct 리턴으로 구현) - return productService.getViolationProducts(pageable); + return productService.getFavoriteProducts(pageable); } }, RECENT_PRODUCT_ORDER("recent-order") { @@ -38,7 +37,7 @@ Page sort(ProductService productService, Pageable pageable) { } }; - private String orderType; + private final String orderType; Sort(String orderType) { this.orderType = orderType; diff --git a/module-api/src/main/java/com/kernel360/member/code/MemberBusinessCode.java b/module-api/src/main/java/com/kernel360/member/code/MemberBusinessCode.java index c8845cc4..27cfc401 100644 --- a/module-api/src/main/java/com/kernel360/member/code/MemberBusinessCode.java +++ b/module-api/src/main/java/com/kernel360/member/code/MemberBusinessCode.java @@ -23,7 +23,6 @@ public enum MemberBusinessCode implements BusinessCode { SUCCESS_REQUEST_RESET_PASSWORD(HttpStatus.OK.value(), "BMC014", "비밀번호가 초기화되었습니다."), SUCCESS_REQUEST_LOGIN_MEMBER_KAKAO(HttpStatus.OK.value(), "BMC015", "로그인 성공"); - private final int status; private final String code; private final String message; diff --git a/module-api/src/main/java/com/kernel360/member/code/MemberErrorCode.java b/module-api/src/main/java/com/kernel360/member/code/MemberErrorCode.java index 377517d3..ccc35921 100644 --- a/module-api/src/main/java/com/kernel360/member/code/MemberErrorCode.java +++ b/module-api/src/main/java/com/kernel360/member/code/MemberErrorCode.java @@ -4,8 +4,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -import java.util.EnumSet; - @RequiredArgsConstructor public enum MemberErrorCode implements ErrorCode { @@ -19,7 +17,6 @@ public enum MemberErrorCode implements ErrorCode { EXPIRED_PASSWORD_RESET_TOKEN(HttpStatus.NOT_FOUND.value(), "EMC008", "유효하지 않은 비밀번호 초기화 토큰입니다"), FAILED_REQUEST_LOGIN_FOR_KAKAO(HttpStatus.BAD_REQUEST.value(), "EMC009", "카카오 로그인 정보를 찾을 수 없습니다."); - private final int status; private final String code; private final String message; diff --git a/module-api/src/main/java/com/kernel360/member/controller/MemberController.java b/module-api/src/main/java/com/kernel360/member/controller/MemberController.java index 77037d86..8057428a 100644 --- a/module-api/src/main/java/com/kernel360/member/controller/MemberController.java +++ b/module-api/src/main/java/com/kernel360/member/controller/MemberController.java @@ -3,14 +3,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.kernel360.carinfo.entity.CarInfo; -import com.kernel360.exception.BusinessException; import com.kernel360.member.code.MemberBusinessCode; -import com.kernel360.member.code.MemberErrorCode; import com.kernel360.member.dto.CarInfoDto; -import com.kernel360.member.dto.FindMemberIdFromEmailDto; +import com.kernel360.member.dto.MemberCredentialDto; import com.kernel360.member.dto.MemberDto; import com.kernel360.member.dto.WashInfoDto; -import com.kernel360.member.service.FindService; +import com.kernel360.member.service.FindCredentialService; import com.kernel360.member.service.MemberService; import com.kernel360.response.ApiResponse; import com.kernel360.washinfo.entity.WashInfo; @@ -30,7 +28,7 @@ public class MemberController { private final MemberService memberService; - private final FindService findService; + private final FindCredentialService findCredentialService; @PostMapping("/join") public ResponseEntity> joinMember(@RequestBody MemberDto joinRequestDto) { @@ -51,12 +49,14 @@ public ResponseEntity> login(@RequestBody MemberDto login } @GetMapping("/duplicatedCheckId/{id}") - public boolean duplicatedCheckId (@PathVariable String id){ + public boolean duplicatedCheckId(@PathVariable String id) { + return memberService.idDuplicationCheck(id); } @GetMapping("/duplicatedCheckEmail/{email}") - public boolean duplicatedCheckEmail (@PathVariable String email){ + public boolean duplicatedCheckEmail(@PathVariable String email) { + return memberService.emailDuplicationCheck(email); } @@ -74,18 +74,40 @@ public ResponseEntity> saveCarInfo(@RequestBody CarInfoDto return ApiResponse.toResponseEntity(MemberBusinessCode.SUCCESS_REQUEST_UPDATE_CAR_INFO_MEMBER); } - @PostMapping("/find/memberId") - public ResponseEntity> sendMemberIdByEmail(@RequestBody FindMemberIdFromEmailDto dto) { - String memberId = memberService.findByEmail(dto.email()).id(); + @PostMapping("/find-memberId") + public ResponseEntity> sendMemberIdByEmail(@RequestBody MemberCredentialDto credentialDto) { + String memberId = memberService.findByEmail(credentialDto.email()).id(); + + findCredentialService.sendMemberId(credentialDto.email(), memberId); + + return ApiResponse.toResponseEntity(MemberBusinessCode.SUCCESS_REQUEST_FIND_MEMBER_ID); + } + + @PostMapping("/find-password") + public ResponseEntity> sendPasswordResetUriByEmail(@RequestBody MemberCredentialDto dto) { + //--입력받은 아이디를 데이터베이스에 조회, 없으면 예외 발생--/ + MemberDto memberDto = memberService.findByMemberId(dto.memberId()); + //--유효성이 검증된 아이디에 대해서 만료시간이 있는 비밀번호 초기화 (호스트 + UUID) 링크 생성 --// + String resetUri = findCredentialService.generatePasswordResetUri( memberDto); + //-- 가입시 입력한 이메일로 비밀번호 초기화 이메일 발송 --// + findCredentialService.sendPasswordResetUri(resetUri, memberDto); + + return ApiResponse.toResponseEntity(MemberBusinessCode.SUCCESS_REQUEST_SEND_RESET_PASSWORD_EMAIL); + } - if (memberId.isEmpty()) { - throw new BusinessException(MemberErrorCode.FAILED_FIND_MEMBER_INFO); - } + @GetMapping("/reset-password") + public ResponseEntity> getPasswordResetPage(@RequestParam String token) { + findCredentialService.getData(token); + + return ApiResponse.toResponseEntity(MemberBusinessCode.SUCCESS_REQUEST_RESET_PASSWORD_PAGE, token); + } - findService.sendMemberId(dto.email(), memberId); + @PostMapping("/reset-password") + public ResponseEntity> resetPassword(@RequestBody MemberCredentialDto credentialDto) { + String authKey = findCredentialService.resetPassword(credentialDto); + findCredentialService.getAndExpireData(authKey); - return ApiResponse.toResponseEntity( - MemberBusinessCode.SUCCESS_REQUEST_FIND_MEMBER_ID); // 아이디는 이메일로 보내고, 외부 노출 X + return ApiResponse.toResponseEntity(MemberBusinessCode.SUCCESS_REQUEST_RESET_PASSWORD); } @GetMapping("/login/forKakao") diff --git a/module-api/src/main/java/com/kernel360/member/dto/FindMemberIdFromEmailDto.java b/module-api/src/main/java/com/kernel360/member/dto/FindMemberIdFromEmailDto.java deleted file mode 100644 index 21f87815..00000000 --- a/module-api/src/main/java/com/kernel360/member/dto/FindMemberIdFromEmailDto.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.kernel360.member.dto; - -public record FindMemberIdFromEmailDto(String email) { - public static FindMemberIdFromEmailDto of(String email) { - return new FindMemberIdFromEmailDto(email); - } -} diff --git a/module-api/src/main/java/com/kernel360/member/dto/MemberCredentialDto.java b/module-api/src/main/java/com/kernel360/member/dto/MemberCredentialDto.java new file mode 100644 index 00000000..4601f95d --- /dev/null +++ b/module-api/src/main/java/com/kernel360/member/dto/MemberCredentialDto.java @@ -0,0 +1,13 @@ +package com.kernel360.member.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record MemberCredentialDto(@JsonProperty("authToken") String authToken, + @JsonProperty("email") String email, + @JsonProperty("memberId") String memberId, + @JsonProperty("password") String password) { + public static MemberCredentialDto of(String authToken, String email, String memberId, String password) { + + return new MemberCredentialDto(authToken, email, memberId, password); + } +} diff --git a/module-api/src/main/java/com/kernel360/member/service/EmailSender.java b/module-api/src/main/java/com/kernel360/member/service/EmailSender.java index dd25da0c..1668499d 100644 --- a/module-api/src/main/java/com/kernel360/member/service/EmailSender.java +++ b/module-api/src/main/java/com/kernel360/member/service/EmailSender.java @@ -1,5 +1,5 @@ package com.kernel360.member.service; public interface EmailSender { - void sendMemberId(String email, String memberId); + void sendMail(String to, String subject, String content); } diff --git a/module-api/src/main/java/com/kernel360/member/service/FindCredentialService.java b/module-api/src/main/java/com/kernel360/member/service/FindCredentialService.java new file mode 100644 index 00000000..fc3e4f1b --- /dev/null +++ b/module-api/src/main/java/com/kernel360/member/service/FindCredentialService.java @@ -0,0 +1,164 @@ +package com.kernel360.member.service; + +import com.kernel360.exception.BusinessException; +import com.kernel360.member.code.MemberErrorCode; +import com.kernel360.member.dto.MemberCredentialDto; +import com.kernel360.member.dto.MemberDto; +import java.time.Duration; +import java.util.Objects; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +@RequiredArgsConstructor +public class FindCredentialService implements RedisUtils { + + @Value("${constants.password-reset-token.duration-minute}") + private int TOKEN_DURATION; + + @Value("${constants.host-url}") + private String HOST_HTTP_URL; + + private final RedisTemplate redisTemplate; + + private final MemberService memberService; + + private final SendEmailService emailService; + + public void sendMemberId(String email, String memberId) { + String textContent = "가입하신 아이디는 " + "'" + memberId + "' 입니다."; + String htmlContent = + "
\n" + + + " \n" + + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "

Wash-Fit 아이디/패스워드 찾기

\n" + + "
\n" + textContent + + "
\n" + + "
\n" + + "

기타 질문사항은 고객센터에 문의 주시길 바랍니다.

\n" + + "
\n" + + "
"; + + emailService.sendMail(email, "[No-Reply] Wash-Fit 아이디/비밀번호 찾기", htmlContent); + } + + public void sendPasswordResetUri(String resetUri, MemberDto dto) { + + String htmlContent = "\n" + + "\n" + + "\n" + + " \n" + + " Password Recovery\n" + + "\n" + + "\n" + + "
\n" + + "
\n" + + " \"WashFit\"\n" + + "

WashFit 계정의 비밀번호를 변경합니다.

\n" + + "

아래의 링크를 통해 패스워드를 재설정하세요.

\n" + + " \n" + + " 패스워드 재설정하기\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "

고객센터 운영시간

\n" + + "

평일 10:00~19:00 (주말 및 공휴일 제외/ 점심시간 13:00~14:00)

\n" + + " " + + "고객센터 문의하기\n" + + "
\n" + + "
\n" + + "

FAST CAMPUS

\n" + + "

(주)데이원캠퍼니

\n" + + "

서울특별시 강남구 테헤란로 231, 센트럴프라자 WEST 6층, 7층

\n" + + "
\n" + + "
\n" + + "\n" + + ""; + + emailService.sendMail(dto.email(), "[No-Reply] Wash-Fit 아이디/비밀번호 찾기", htmlContent); + } + + public String generatePasswordResetUri(MemberDto memberDto) { + String resetToken = generateUUID(); + + String uriString = UriComponentsBuilder.fromHttpUrl(HOST_HTTP_URL) + .path("/member/reset-password") + .queryParam("token", resetToken) + .build() + .toUriString(); + setExpiringData(resetToken, memberDto.id(), TOKEN_DURATION); // duration 값 상수로 변경관리 필요 + + return uriString; + } + + public String resetPassword(MemberCredentialDto credentialDto) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + String value = valueOperations.get(credentialDto.authToken()); + + if (Objects.isNull(value)) { + throw new BusinessException(MemberErrorCode.EXPIRED_PASSWORD_RESET_TOKEN); + } + + memberService.resetPasswordByMemberId(value, credentialDto.password()); + + return credentialDto.authToken(); + } + + private String generateUUID() { + + return UUID.randomUUID().toString(); + } + + @Override + public String getData(String key) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + if (Objects.isNull(valueOperations.get(key))) { + throw new BusinessException(MemberErrorCode.EXPIRED_PASSWORD_RESET_TOKEN); + } + + return valueOperations.get(key); + } + + @Override + public void setData(String key, String value) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + valueOperations.set(key, value); + } + + @Override + public void setExpiringData(String key, String value, int duration) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + Duration expireDuration = Duration.ofMinutes(duration); + valueOperations.set(key, value, expireDuration); + } + + @Override + public void getAndExpireData(String key) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + if (Objects.isNull(valueOperations.get(key))) { + throw new BusinessException(MemberErrorCode.EXPIRED_PASSWORD_RESET_TOKEN); + } + + valueOperations.getAndDelete(key); + } +} diff --git a/module-api/src/main/java/com/kernel360/member/service/MemberService.java b/module-api/src/main/java/com/kernel360/member/service/MemberService.java index 5eef8f33..4b46f250 100644 --- a/module-api/src/main/java/com/kernel360/member/service/MemberService.java +++ b/module-api/src/main/java/com/kernel360/member/service/MemberService.java @@ -189,6 +189,7 @@ public MemberDto findByMemberId(String memberId) { return MemberDto.from(member); } + @Transactional public void resetPasswordByMemberId(String memberId, String newPassword) { Member member = memberRepository.findOneById(memberId); @@ -198,6 +199,7 @@ public void resetPasswordByMemberId(String memberId, String newPassword) { member.updatePassword(ConvertSHA256.convertToSHA256(newPassword)); } + @Transactional public MemberDto loginForKakao(String accessToken) { diff --git a/module-api/src/main/java/com/kernel360/member/service/RedisUtils.java b/module-api/src/main/java/com/kernel360/member/service/RedisUtils.java new file mode 100644 index 00000000..efb769af --- /dev/null +++ b/module-api/src/main/java/com/kernel360/member/service/RedisUtils.java @@ -0,0 +1,12 @@ +package com.kernel360.member.service; + +public interface RedisUtils { + + Object getData(String key); + + void setData(String key, String value); + + void setExpiringData(String key, String value, int duration); + + void getAndExpireData(String key); +} diff --git a/module-api/src/main/java/com/kernel360/member/service/FindService.java b/module-api/src/main/java/com/kernel360/member/service/SendEmailService.java similarity index 56% rename from module-api/src/main/java/com/kernel360/member/service/FindService.java rename to module-api/src/main/java/com/kernel360/member/service/SendEmailService.java index 1b27b55f..8f3e9237 100644 --- a/module-api/src/main/java/com/kernel360/member/service/FindService.java +++ b/module-api/src/main/java/com/kernel360/member/service/SendEmailService.java @@ -7,17 +7,18 @@ @Service @RequiredArgsConstructor -public class FindService implements EmailSender { +public class SendEmailService implements EmailSender { private final JavaMailSender mailSender; - public void sendMemberId(String email, String memberId) { + @Override + public void sendMail(String to, String subject, String content) { + new Thread(() -> mailSender.send(mimeMessage -> { MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, true); - mimeMessageHelper.setTo(email); - mimeMessageHelper.setSubject("Wash-Fit 아이디/패스워드 찾기"); - String textContent = "가입하신 아이디는 " + "'" + memberId + "' 입니다."; - mimeMessageHelper.setText(textContent); + mimeMessageHelper.setTo(to); + mimeMessageHelper.setSubject(subject); + mimeMessageHelper.setText(content, true); })).start(); } } diff --git a/module-api/src/main/java/com/kernel360/product/service/ProductService.java b/module-api/src/main/java/com/kernel360/product/service/ProductService.java index 2ed6e3d8..9772eb0b 100644 --- a/module-api/src/main/java/com/kernel360/product/service/ProductService.java +++ b/module-api/src/main/java/com/kernel360/product/service/ProductService.java @@ -1,15 +1,20 @@ package com.kernel360.product.service; import com.kernel360.exception.BusinessException; +import com.kernel360.likes.repository.LikeRepository; import com.kernel360.main.code.ProductsErrorCode; import com.kernel360.main.dto.RecommendProductsDto; import com.kernel360.product.dto.ProductDetailDto; import com.kernel360.product.dto.ProductDto; import com.kernel360.product.entity.Product; +import com.kernel360.product.entity.SafetyStatus; import com.kernel360.product.repository.ProductRepository; + import java.util.List; + import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,14 +24,15 @@ public class ProductService { private final ProductRepository productRepository; + private final LikeRepository likeRepository; @Transactional(readOnly = true) public List getProducts() { return productRepository.findAll() - .stream() - .map(ProductDto::from) - .toList(); + .stream() + .map(ProductDto::from) + .toList(); } @Transactional(readOnly = true) @@ -38,11 +44,8 @@ public Page getProductsByKeyword(String keyword, Pageable pageable) @Transactional(readOnly = true) public Page getProductListOrderByViewCount(Pageable pageable) { - Page products = productRepository.findTop5ByOrderByProductNameDesc(pageable); -// Page products = productRepository.findAllByOrderByViewCountDesc(); - //FIXME:: viewCount 값이 존재하지 않아,제품이룸순 데이터를 샘플로 전달한 후 향후 변경 - return products.map(ProductDto::from); + return productRepository.findAllByOrderByViewCountDesc(pageable).map(ProductDto::from); } @Transactional(readOnly = true) @@ -55,13 +58,8 @@ public Page getRecommendProducts(Pageable pageable) { @Transactional(readOnly = true) public Page getViolationProducts(Pageable pageable) { - return productRepository.findAllByOrderByCreatedAtDesc(pageable) - .map(ProductDto::from); - //FIXME :: 데이터가 없어서 최근데이터로 대신 리턴 -// return productRepository.findAllBySafetyStatusEquals(SafetyStatus.DANGER) -// .stream() -// .map(ProductDto::from) -// .toList(); + return productRepository.findAllBySafetyStatusEquals(SafetyStatus.DANGER, pageable) + .map(ProductDto::from); } @Transactional(readOnly = true) @@ -75,7 +73,7 @@ public Page getRecentProducts(Pageable pageable) { @Transactional(readOnly = true) public ProductDetailDto getProductById(Long id) { - return productRepository.findById(id) + return productRepository.findById(id) .map(ProductDetailDto::from) .orElseThrow(() -> new BusinessException(ProductsErrorCode.NOT_FOUND_PRODUCT)); } @@ -84,11 +82,29 @@ public ProductDetailDto getProductById(Long id) { public void updateViewCount(Long id) { productRepository.updateViewCount(id); } - + @Transactional(readOnly = true) public Page getProductByOCR(String reportNo, Pageable pageable) { - return productRepository.findProductByReportNumberEquals(reportNo, pageable) + return productRepository.findProductByReportNumberEquals(reportNo, pageable) .map(ProductDetailDto::from); } + + public Page getFavoriteProducts(Pageable pageable) { + Page results = likeRepository.findTop20ByProductNoOrderByLikeCountDesc(pageable); + + List productDtos = results.getContent().stream() + .map(result -> { + Long productNo = (Long) result[0]; + Product product = productRepository.findById(productNo) + .orElseThrow(() -> new BusinessException(ProductsErrorCode.NOT_FOUND_PRODUCT)); + return ProductDto.from(product); + }) + .toList(); + + return new PageImpl<>(productDtos, pageable, results.getTotalElements()); + } + + + } diff --git a/module-api/src/main/resources/application-dev.yml b/module-api/src/main/resources/application-dev.yml index 5fb04ea0..e6fea07d 100644 --- a/module-api/src/main/resources/application-dev.yml +++ b/module-api/src/main/resources/application-dev.yml @@ -32,6 +32,17 @@ spring: enable: true timeout: 5000 writetimeout: 5000 + + data: + redis: + port: 6379 + lettuce: + pool: + max-active: 20 + max-idle: 20 + host: ${REDIS_DEV_HOST} + password: ${REDIS_DEV_PASSWORD} + logging: file: path: ./logs/api/ @@ -49,4 +60,18 @@ management: include: "*" endpoint: health: - show-details: always \ No newline at end of file + show-details: always + +constants: + host-url: ${DEV_HOST_URL} + password-reset-token: + duration-minute: 5 + +aws: + credentials: + access-key-id: ${AWS_ACCESS_KEY} + secret-access-key: ${AWS_SECRET_KEY} + s3: + bucket: + name: ${AWS_S3_BUCKET_NAME} + url: ${AWS_S3_BUCKET_URL} \ No newline at end of file diff --git a/module-api/src/main/resources/application-local.yml b/module-api/src/main/resources/application-local.yml index e4d18610..323c548b 100644 --- a/module-api/src/main/resources/application-local.yml +++ b/module-api/src/main/resources/application-local.yml @@ -34,6 +34,17 @@ spring: enable: true timeout: 5000 writetimeout: 5000 + + data: + redis: + port: 6379 + lettuce: + pool: + max-active: 20 + max-idle: 20 + host: 127.0.0.1 + password: wash1234 + jasypt: encryptor: algorithm: PBEWithMD5AndTripleDES @@ -55,4 +66,18 @@ management: include: "*" endpoint: health: - show-details: always \ No newline at end of file + show-details: always + +constants: + host-url: "http://localhost:8080" + password-reset-token: + duration-minute: 5 + +aws: + credentials: + access-key-id: ENC(vx2H0E5tIohXqzynDez9M7Kn3Vjj0t3sS3Wq6uyN5kgYeXhDyIylOA==) + secret-access-key: ENC(m/2qCJve4UP86W7Q0ZQhKSDGBnltnbGOnso7d/9wiNovq1Mzyto608rUnCQ+AhqUThpClK1cmxSOj6WHfAbZTQ==) + s3: + bucket: + name: ENC(JQIi11b8LB+99FnX02wCGwdXTOEax3VkuzgNqAVshK4=) + url: ENC(9P2gRaZoGkR4SCgoTS/6sEQP0kVWwVWFaDckr1/FUoRV1MPnGXQL6OJKsGlHegk8h1d69uFDKTuZpLntfyn3nVMXLz18t8ls) \ No newline at end of file diff --git a/module-api/src/main/resources/application-prod.yml b/module-api/src/main/resources/application-prod.yml index b678eb3d..983e1954 100644 --- a/module-api/src/main/resources/application-prod.yml +++ b/module-api/src/main/resources/application-prod.yml @@ -33,6 +33,16 @@ spring: timeout: 5000 writetimeout: 5000 + data: + redis: + port: 6379 + lettuce: + pool: + max-active: 20 + max-idle: 20 + host: ${REDIS_PROD_HOST} + password: ${REDIS_PROD_PASSWORD} + logging: file: path: ./logs/api/ @@ -50,4 +60,18 @@ management: include: "*" endpoint: health: - show-details: always \ No newline at end of file + show-details: always + +constants: + host-url: ${PROD_HOST_URL} + password-reset-token: + duration-minute: 5 + +aws: + credentials: + access-key-id: ${AWS_ACCESS_KEY} + secret-access-key: ${AWS_SECRET_KEY} + s3: + bucket: + name: ${AWS_S3_BUCKET_NAME} + url: ${AWS_S3_BUCKET_URL} \ No newline at end of file diff --git a/module-api/src/main/resources/application.yml b/module-api/src/main/resources/application.yml index ef46c2ad..cc7635e9 100644 --- a/module-api/src/main/resources/application.yml +++ b/module-api/src/main/resources/application.yml @@ -1,3 +1,7 @@ spring: profiles: - active: local \ No newline at end of file + active: local + servlet: + multipart: + max-file-size: 50MB + max-request-size: 500MB \ No newline at end of file diff --git a/module-api/src/main/resources/db/migration/V1.0.7__create_admin_table.sql b/module-api/src/main/resources/db/migration/V1.0.7__create_admin_table.sql new file mode 100644 index 00000000..aa1aa484 --- /dev/null +++ b/module-api/src/main/resources/db/migration/V1.0.7__create_admin_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS admin +( + admin_no BIGSERIAL PRIMARY KEY, + id VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + created_at DATE NOT NULL, + created_by VARCHAR NOT NULL, + modified_at DATE, + modified_by VARCHAR, + CONSTRAINT admin_id_unique UNIQUE (id), + CONSTRAINT admin_email_unique UNIQUE (email) + ); + + +alter sequence admin_admin_no_seq increment by 50; diff --git a/module-api/src/test/java/com/kernel360/common/ControllerTest.java b/module-api/src/test/java/com/kernel360/common/ControllerTest.java index 67eedd7c..b43a8adc 100644 --- a/module-api/src/test/java/com/kernel360/common/ControllerTest.java +++ b/module-api/src/test/java/com/kernel360/common/ControllerTest.java @@ -9,7 +9,7 @@ import com.kernel360.main.controller.MainContoller; import com.kernel360.main.service.MainService; import com.kernel360.member.controller.MemberController; -import com.kernel360.member.service.FindService; +import com.kernel360.member.service.FindCredentialService; import com.kernel360.member.service.MemberService; import com.kernel360.mypage.controller.MyPageController; import com.kernel360.product.controller.ProductController; @@ -53,7 +53,7 @@ public abstract class ControllerTest { protected MainService mainService; @MockBean - protected FindService findService; + protected FindCredentialService findCredentialService; @MockBean protected AuthService authService; diff --git a/module-api/src/test/java/com/kernel360/member/controller/MemberControllerTest.java b/module-api/src/test/java/com/kernel360/member/controller/MemberControllerTest.java index 6ab56c1f..829802ff 100644 --- a/module-api/src/test/java/com/kernel360/member/controller/MemberControllerTest.java +++ b/module-api/src/test/java/com/kernel360/member/controller/MemberControllerTest.java @@ -1,15 +1,25 @@ package com.kernel360.member.controller; +import static com.kernel360.common.utils.RestDocumentUtils.getDocumentRequest; +import static com.kernel360.common.utils.RestDocumentUtils.getDocumentResponse; import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import com.fasterxml.jackson.databind.ObjectMapper; import com.kernel360.common.ControllerTest; -import com.kernel360.member.dto.FindMemberIdFromEmailDto; +import com.kernel360.member.dto.MemberCredentialDto; import com.kernel360.member.dto.MemberDto; import java.time.LocalDate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; @@ -102,7 +112,7 @@ class MemberControllerTest extends ControllerTest { @DisplayName("이메일을 입력 받아 아이디 찾기 이메일 발송") void 아이디_찾기_이메일_발송_검사() throws Exception { /** given 목데이터 세팅 **/ - FindMemberIdFromEmailDto dto = FindMemberIdFromEmailDto.of("kernel360@gmail.com"); + MemberCredentialDto dto = MemberCredentialDto.of(null, "kernel360@gmail.com", null, null); MemberDto memberDto = MemberDto.of("testMemberId", "testPassword001"); given(memberService.findByEmail(dto.email())).willReturn(memberDto); @@ -111,9 +121,109 @@ class MemberControllerTest extends ControllerTest { String dtoAsString = objectMapper.writeValueAsString(dto); /** then **/ - mockMvc.perform(MockMvcRequestBuilders.post("/member/find/memberId") + mockMvc.perform(MockMvcRequestBuilders.post("/member/find-memberId") .contentType(MediaType.APPLICATION_JSON) .content(dtoAsString)) - .andExpect(MockMvcResultMatchers.status().isOk()).andReturn(); + .andExpect(MockMvcResultMatchers.status().isOk()).andDo(document( + "member/find-memberId", getDocumentRequest(), getDocumentResponse(), + requestFields( + fieldWithPath("email").type(JsonFieldType.STRING).description("회원가입시 입력한 이메일"), + fieldWithPath("authToken").type(JsonFieldType.NULL).description("비밀번호 재설정 UUID 토큰(사용하지 않음)"), + fieldWithPath("memberId").type(JsonFieldType.NULL).description("회원 아이디(사용하지 않음)"), + fieldWithPath("password").type(JsonFieldType.NULL).description("변경할 비밀번호(사용하지 않음)") + ), + responseFields( + fieldWithPath("status").type(JsonFieldType.NUMBER).description("HTTP 상태 코드"), + fieldWithPath("code").type(JsonFieldType.STRING).description("비즈니스 코드"), + fieldWithPath("message").type(JsonFieldType.STRING).description("상세 메시지"), + fieldWithPath("value").type(JsonFieldType.NULL).description("JSON BODY 데이터") + ))); } -} \ No newline at end of file + + @Test + @DisplayName("회원 아이디를 입력받아 비밀번호 재설정 이메일 발송에 성공한다") + void 회원_아이디로_비밀번호_재설정_이메일_발송_검사() throws Exception { + // given + MemberCredentialDto credentialDto = MemberCredentialDto.of(null, null, "kernel360", null); + MemberDto memberDto = MemberDto.of("testMemberId", "testPassword001"); + + given(memberService.findByMemberId(credentialDto.memberId())).willReturn(memberDto); + given(findCredentialService.generatePasswordResetUri(memberDto)).willReturn("테스트 URI"); + + ObjectMapper objectMapper = new ObjectMapper(); + String dtoAsString = objectMapper.writeValueAsString(credentialDto); + + // then + mockMvc.perform(MockMvcRequestBuilders.post("/member/find-password") + .contentType(MediaType.APPLICATION_JSON) + .content(dtoAsString)) + .andExpect(MockMvcResultMatchers.status().isOk()).andDo(document( + "member/find-password", getDocumentRequest(), getDocumentResponse(), + requestFields( + fieldWithPath("email").type(JsonFieldType.NULL).description("회원가입시 입력한 이메일(사용하지 않음)"), + fieldWithPath("authToken").type(JsonFieldType.NULL).description("비밀번호 재설정 UUID 토큰(사용하지 않음)"), + fieldWithPath("memberId").type(JsonFieldType.STRING).description("회원 아이디"), + fieldWithPath("password").type(JsonFieldType.NULL).description("변경할 비밀번호(사용하지 않음)") + ), + responseFields( + fieldWithPath("status").type(JsonFieldType.NUMBER).description("HTTP 상태 코드"), + fieldWithPath("code").type(JsonFieldType.STRING).description("비즈니스 코드"), + fieldWithPath("message").type(JsonFieldType.STRING).description("상세 메시지"), + fieldWithPath("value").type(JsonFieldType.NULL).description("JSON BODY 데이터") + ))); + } + + @Test + @DisplayName("비밀번호 재설정 토큰을 확인하여 비밀번호 재설정 페이지 반환에 성공") + void 비밀번호_재설정_토큰을_확인하고_비밀번호_재설정_페이지로_이동_검사() throws Exception { + String token = "testToken-1234-5678"; + given(findCredentialService.getData(token)).willReturn("kernel360-testId"); + + mockMvc.perform(MockMvcRequestBuilders.get("/member/reset-password?token="+token) + .contentType(MediaType.APPLICATION_JSON) + .content(token)) + .andExpect(MockMvcResultMatchers.status().isFound()).andDo(document( + "member/get-reset-password", getDocumentRequest(), getDocumentResponse(), + queryParameters( + parameterWithName("token").description("비밀번호 재설정 UUID 토큰") + ), + responseFields( + fieldWithPath("status").type(JsonFieldType.NUMBER).description("HTTP 상태 코드"), + fieldWithPath("code").type(JsonFieldType.STRING).description("비즈니스 코드"), + fieldWithPath("message").type(JsonFieldType.STRING).description("상세 메시지"), + fieldWithPath("value").type(JsonFieldType.STRING).description("JSON BODY 데이터 - 비밀번호 재설정 토큰") + ) + )); + } + + @Test + @DisplayName("재설정할 비밀번호와 재설정 토큰을 입력받아 비밀번호 재설정에 성공한다") + void 비밀번호_재설정_검사() throws Exception{ + MemberCredentialDto credentialDto = MemberCredentialDto.of("testToken", null, null, "resetPassword"); + given(findCredentialService.resetPassword(credentialDto)).willReturn("testToken"); + + ObjectMapper objectMapper = new ObjectMapper(); + String dtoAsString = objectMapper.writeValueAsString(credentialDto); + + mockMvc.perform(MockMvcRequestBuilders.post("/member/reset-password") + .contentType(MediaType.APPLICATION_JSON) + .content(dtoAsString)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andDo(document( + "member/post-reset-password", getDocumentRequest(), getDocumentResponse(), + requestFields( + fieldWithPath("authToken").type(JsonFieldType.STRING).description("비밀번호 재설정 UUID 토큰"), + fieldWithPath("email").type(JsonFieldType.NULL).description("회원가입시 입력한 이메일(사용하지 않음)"), + fieldWithPath("memberId").type(JsonFieldType.NULL).description("회원 아이디(사용하지 않음)"), + fieldWithPath("password").type(JsonFieldType.STRING).description("변경할 비밀번호") + ), + responseFields( + fieldWithPath("status").type(JsonFieldType.NUMBER).description("HTTP 상태 코드"), + fieldWithPath("code").type(JsonFieldType.STRING).description("비즈니스 코드"), + fieldWithPath("message").type(JsonFieldType.STRING).description("상세 메시지"), + fieldWithPath("value").type(JsonFieldType.NULL).description("JSON BODY 데이터") + ) + )); + } +} + diff --git a/module-api/src/test/java/com/kernel360/product/service/ProductServiceTest.java b/module-api/src/test/java/com/kernel360/product/service/ProductServiceTest.java index 0c22d68c..1a11e022 100644 --- a/module-api/src/test/java/com/kernel360/product/service/ProductServiceTest.java +++ b/module-api/src/test/java/com/kernel360/product/service/ProductServiceTest.java @@ -1,5 +1,6 @@ package com.kernel360.product.service; +import com.kernel360.likes.repository.LikeRepository; import com.kernel360.product.dto.ProductDetailDto; import com.kernel360.product.dto.ProductDto; import com.kernel360.product.entity.Product; @@ -27,6 +28,8 @@ class ProductServiceTest { private ProductRepository productRepository; private ProductService productService; private Pageable pageable; + + private LikeRepository likeRepository; @BeforeEach void 테스트준비() { fixtureMonkey = FixtureMonkey.builder() @@ -43,7 +46,7 @@ class ProductServiceTest { productRepository = mock(ProductRepository.class); pageable = mock(Pageable.class); - productService = new ProductService(productRepository); + productService = new ProductService(productRepository, likeRepository); } @Test diff --git a/module-common/build.gradle b/module-common/build.gradle index 76e62f61..5d09994c 100644 --- a/module-common/build.gradle +++ b/module-common/build.gradle @@ -41,6 +41,9 @@ dependencies { // codec implementation 'commons-codec:commons-codec:1.15' + + // aws s3 + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.625' } tasks.named('test') { diff --git a/module-common/src/main/java/com/kernel360/code/common/CommonErrorCode.java b/module-common/src/main/java/com/kernel360/code/common/CommonErrorCode.java index 331b954f..07ce8680 100644 --- a/module-common/src/main/java/com/kernel360/code/common/CommonErrorCode.java +++ b/module-common/src/main/java/com/kernel360/code/common/CommonErrorCode.java @@ -4,7 +4,9 @@ import org.springframework.http.HttpStatus; public enum CommonErrorCode implements ErrorCode { - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "E001", "Server Error"); + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "E001", "Server Error"), + FAIL_FILE_UPLOAD(HttpStatus.INTERNAL_SERVER_ERROR.value(), "E002", "파일 업로드 실패"), + INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST.value(), "E003", "유효하지 않은 파일 확장자"); private final int status; private final String code; diff --git a/module-common/src/main/java/com/kernel360/config/S3Config.java b/module-common/src/main/java/com/kernel360/config/S3Config.java new file mode 100644 index 00000000..e71411b4 --- /dev/null +++ b/module-common/src/main/java/com/kernel360/config/S3Config.java @@ -0,0 +1,30 @@ +package com.kernel360.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + @Value("${aws.credentials.access-key-id}") + private String accessKey; + + @Value("${aws.credentials.secret-access-key}") + private String secretKey; + + @Bean + public AmazonS3 amazonS3Client() { + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider( + new BasicAWSCredentials(accessKey, secretKey) + )) + .withRegion(Regions.AP_NORTHEAST_2) + .build(); + } +} \ No newline at end of file diff --git a/module-common/src/main/java/com/kernel360/utils/file/FileUtils.java b/module-common/src/main/java/com/kernel360/utils/file/FileUtils.java new file mode 100644 index 00000000..bd7ddbca --- /dev/null +++ b/module-common/src/main/java/com/kernel360/utils/file/FileUtils.java @@ -0,0 +1,89 @@ +package com.kernel360.utils.file; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.kernel360.code.common.CommonErrorCode; +import com.kernel360.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class FileUtils { + + private final AmazonS3 amazonS3; + + @Value("${aws.s3.bucket.name}") + private String bucketName; + + @Value("${aws.s3.bucket.url}") + private String bucketUrl; + + @Value("${spring.profiles.active}") + private String profile; + + public String upload(S3BucketPath s3BucketPath, MultipartFile multipartFile) { + String filePath = makeFilePath(s3BucketPath); + String filename = makeFileName(); + String fileExtension = getFileExtension(multipartFile.getOriginalFilename()); + String fileKey = String.join("", filePath, filename, fileExtension); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(multipartFile.getContentType()); + metadata.setContentLength(multipartFile.getSize()); + + try { + amazonS3.putObject( + new PutObjectRequest( + bucketName, + fileKey, + multipartFile.getInputStream(), + metadata + ).withCannedAcl(CannedAccessControlList.PublicRead) + ); + } catch (IOException e) { + throw new BusinessException(CommonErrorCode.FAIL_FILE_UPLOAD); + } + + return amazonS3.getUrl(bucketName, fileKey).toString(); + } + + private String makeFilePath(S3BucketPath s3BucketPath) { + + return String.join( + "/", + profile, + s3BucketPath.getModulePath(), + s3BucketPath.getDomainPath(), + s3BucketPath.getCustomPath()); + } + + private String makeFileName() { + + return String.join( + "-", + LocalDate.now(ZoneId.of("Asia/Seoul")).toString(), + UUID.randomUUID().toString()); + } + + private String getFileExtension(String originalFilename) { + try { + return originalFilename.substring(originalFilename.lastIndexOf(".")); + } catch (StringIndexOutOfBoundsException e) { + throw new BusinessException(CommonErrorCode.INVALID_FILE_EXTENSION); + } + } + + public void delete(String fileUrl) { + amazonS3.deleteObject(bucketName, fileUrl.split(bucketUrl)[1]); + } +} diff --git a/module-common/src/main/java/com/kernel360/utils/file/S3BucketPath.java b/module-common/src/main/java/com/kernel360/utils/file/S3BucketPath.java new file mode 100644 index 00000000..3a7d0f27 --- /dev/null +++ b/module-common/src/main/java/com/kernel360/utils/file/S3BucketPath.java @@ -0,0 +1,12 @@ +package com.kernel360.utils.file; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class S3BucketPath { + private final String modulePath; + private final String domainPath; + private final String customPath; +} diff --git a/module-domain/src/main/java/com/kernel360/likes/repository/LikeRepository.java b/module-domain/src/main/java/com/kernel360/likes/repository/LikeRepository.java index 73a86690..6a198dab 100644 --- a/module-domain/src/main/java/com/kernel360/likes/repository/LikeRepository.java +++ b/module-domain/src/main/java/com/kernel360/likes/repository/LikeRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -12,6 +13,9 @@ @Repository public interface LikeRepository extends JpaRepository { Optional findByMemberNoAndProductNo(Long memberNo, Long productNo); + Page findAllByMemberNo(Long memberNo, Pageable pageable); + @Query(value = "SELECT l.productNo, COUNT(l) AS likeCount FROM Like l GROUP BY l.productNo ORDER BY likeCount DESC") + Page findTop20ByProductNoOrderByLikeCountDesc(Pageable pageable); } diff --git a/module-domain/src/main/java/com/kernel360/member/entity/Admin.java b/module-domain/src/main/java/com/kernel360/member/entity/Admin.java new file mode 100644 index 00000000..42d0865b --- /dev/null +++ b/module-domain/src/main/java/com/kernel360/member/entity/Admin.java @@ -0,0 +1,66 @@ +package com.kernel360.member.entity; + + +import com.kernel360.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@Entity +@Table(name = "admin") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Admin extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "admin_id_gen") + @SequenceGenerator(name = "admin_id_gen", sequenceName = "admin_admin_no_seq") + @Column(name = "admin_no", nullable = false) + private Long adminNo; + + @Column(name = "id", nullable = false) + private String id; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "password", nullable = false) + private String password; + + private Admin(String id, String password) { + this.id = id; + this.password = password; + } + + public static Admin of(String id, String email, String password) { + + return new Admin(id, email, password); + } + + + private Admin( + String id, + String email, + String password + ) { + this.id = id; + this.email = email; + this.password = password; + } + + public static Admin createJoinMember(String id, String email, String password) { + + return new Admin(id, email, password); + } + + public static Admin loginAdmin(String id, String password) { + + return new Admin(id, password); + } + + public void updatePassword(String password) { + this.password = password; + } + +} diff --git a/module-domain/src/main/java/com/kernel360/member/entity/Member.java b/module-domain/src/main/java/com/kernel360/member/entity/Member.java index 99eaf22b..d27a8380 100644 --- a/module-domain/src/main/java/com/kernel360/member/entity/Member.java +++ b/module-domain/src/main/java/com/kernel360/member/entity/Member.java @@ -20,7 +20,7 @@ public class Member extends BaseEntity { @Column(name = "member_no", nullable = false) private Long memberNo; - @Column(name = "id", nullable = false) + @Column(name = "id", nullable = false, updatable = false) private String id; @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY, optional = false) diff --git a/settings.gradle b/settings.gradle index b137f10c..f125cbab 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,5 @@ rootProject.name = 'washpedia' include 'module-api' include 'module-common' include 'module-domain' -include 'module-batch' \ No newline at end of file +include 'module-batch' +include 'module-admin' \ No newline at end of file