diff --git a/Jenkinsfile b/Jenkinsfile index f197d3f..8174385 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,64 +1,88 @@ pipeline { agent any environment { - JAVA_HOME = '/usr/lib/jvm/java-17-openjdk-amd64' - PATH = "${env.JAVA_HOME}/bin:${env.PATH}" + JAVA_HOME = '/usr/lib/jvm/java-17-openjdk-amd64' + PATH = "${env.JAVA_HOME}/bin:${env.PATH}" + DB_URL = 'jdbc:mariadb://localhost:3306/travel' + DB_USERNAME = 'root' + DB_PASSWORD = '1q!1q!' + JENKINS_SERVER = "172.17.0.2" + NGINX_MINIJIN = "172.17.0.3" + BUILD_PJASYPT = credentials('Pjasypt') + DEPLOY_DJASYPT = credentials('Djasypt') } stages { stage('Checkout') { steps { - // 저장소의 해당 브랜치를 체크아웃합니다. git branch: env.BRANCH_NAME, url: 'https://github.com/team-MiniJin/BE.git' + sh 'java -version' + sh './gradlew -v' + echo "Building branch: ${env.BRANCH_NAME}" + sh ''' + JDBC_DRIVER_PATH="/usr/local/lib/mariadb-java-client-3.3.3.jar" + WORK_DIR="/tmp" + javac -cp .:$JDBC_DRIVER_PATH $WORK_DIR/TestJDBC.java + java -cp .:$WORK_DIR:$JDBC_DRIVER_PATH TestJDBC + ''' } } stage('Build') { + when { branch 'develop' } steps { - script { - if (env.BRANCH_NAME == 'develop' || env.BRANCH_NAME.startsWith('feature/')) { - // 빌드 단계 - sh './gradlew build' - } - } + sh "./gradlew clean build ${BUILD_PJASYPT}" } } stage('Test') { + when { branch pattern: 'develop|feature/.*' } steps { - script { - if (env.BRANCH_NAME == 'develop' || env.BRANCH_NAME.startsWith('feature/')) { - // 테스트 단계 - sh './gradlew test' - } - } + sh "./gradlew test ${BUILD_PJASYPT}" } } stage('Release') { - when { - branch 'release/*' - } + when { branch 'release/*' } steps { - // 릴리스 단계 sh './gradlew publish' } } stage('Hotfix') { - when { - branch 'hotfix/*' - } + when { branch 'hotfix/*' } steps { - // 핫픽스 단계 sh './gradlew build' } } - /* stage('Deploy') { when { - branch 'main' + anyOf { + branch 'develop' + changeRequest() + } } steps { - // 배포 단계 - sh './gradlew deploy' + script { + echo "Using SSH credentials: jenkins-ssh-key" + sshagent (credentials: ['jenkins-ssh-key']) { + try { + sh """ + echo "Starting SSH connection..." + ssh -i ~/.ssh/jenkins_agent_key root@$NGINX_MINIJIN 'echo "SSH connection successful"' + echo "SSH connection established successfully." + echo "Killing 8080, java service" + ssh -i ~/.ssh/jenkins_agent_key root@$NGINX_MINIJIN '/home/user/kill_java.sh' + echo "Killing Complete 8080, java service" + echo "Transferring file..." + scp -i ~/.ssh/jenkins_agent_key /var/jenkins_home/workspace/minijin_BE_develop/build/libs/travel-0.0.1-SNAPSHOT.jar root@${env.NGINX_MINIJIN}:/home/user/ + echo "File transferred successfully." + echo "Deploying the application..." + ssh -i ~/.ssh/jenkins_agent_key -o StrictHostKeyChecking=no root@${env.NGINX_MINIJIN} "nohup java ${env.DEPLOY_DJASYPT} -jar /home/user/travel-0.0.1-SNAPSHOT.jar --server.port=8080 > /home/user/travel.log 2>&1 &" + echo "Application deployed successfully." + """ + } catch (Exception e) { + echo "SSH connection or file transfer failed: ${e}" + error "Stopping pipeline due to SSH failure" + } + } + } } } - */ } -} +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 1012bcd..abfdfc8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,48 +1,62 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.3.0' - id 'io.spring.dependency-management' version '1.1.5' + id 'java' + id 'org.springframework.boot' version '3.3.0' + id 'io.spring.dependency-management' version '1.1.5' } group = 'com.minizin.travel' version = '0.0.1-SNAPSHOT' java { - sourceCompatibility = '17' + sourceCompatibility = '17' } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - // Framework - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - //jwt - implementation 'io.jsonwebtoken:jjwt-api:0.12.3' - implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' - implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' - // lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - // DB - runtimeOnly 'com.h2database:h2' - runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' - //// test for - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Framework + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + // jasypt + implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5' + //jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + // lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + // DB + runtimeOnly 'com.h2database:h2' + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // gson + implementation 'com.google.code.gson:gson:2.8.8' + // okhttp + implementation 'com.squareup.okhttp3:okhttp:4.9.3' + //API DOC + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + //// test for + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.3' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() + systemProperty 'jasypt.encryptor.password', findProperty("jasypt.encryptor.password") } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/gradlew.bat b/gradlew.bat index 25da30d..7101f8e 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,92 +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. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%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 +@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. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%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/src/.DS_Store b/src/.DS_Store index 5a16a58..e1d7135 100644 Binary files a/src/.DS_Store and b/src/.DS_Store differ diff --git a/src/main/java/com/minizin/travel/TravelApplication.java b/src/main/java/com/minizin/travel/TravelApplication.java index fb09f4b..d02d81a 100644 --- a/src/main/java/com/minizin/travel/TravelApplication.java +++ b/src/main/java/com/minizin/travel/TravelApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class TravelApplication { - public static void main(String[] args) { - SpringApplication.run(TravelApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(TravelApplication.class, args); + } } diff --git a/src/main/java/com/minizin/travel/config/CorsMvcConfig.java b/src/main/java/com/minizin/travel/config/CorsMvcConfig.java new file mode 100644 index 0000000..0e9d8e6 --- /dev/null +++ b/src/main/java/com/minizin/travel/config/CorsMvcConfig.java @@ -0,0 +1,24 @@ +package com.minizin.travel.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@Slf4j +public class CorsMvcConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry corsRegistry) { + log.debug("Configuring CORS mappings"); + corsRegistry.addMapping("/**") + .exposedHeaders("Set-Cookie", "Authorization") + .allowedOrigins("http://localhost:3000", "https://fe-two-blond.vercel.app") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowCredentials(true) + .maxAge(3600L); + } + +} diff --git a/src/main/java/com/minizin/travel/config/JasyptConfig.java b/src/main/java/com/minizin/travel/config/JasyptConfig.java new file mode 100644 index 0000000..bd61c22 --- /dev/null +++ b/src/main/java/com/minizin/travel/config/JasyptConfig.java @@ -0,0 +1,35 @@ +package com.minizin.travel.config; + +import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties; +import org.jasypt.encryption.StringEncryptor; +import org.jasypt.encryption.pbe.PooledPBEStringEncryptor; +import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableEncryptableProperties +public class JasyptConfig { + + @Value("${jasypt.encryptor.password}") + private String key; + + @Bean("jasyptStringEncryptor") + public StringEncryptor stringEncryptor() { + PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor(); + SimpleStringPBEConfig config = new SimpleStringPBEConfig(); + + config.setPassword(key); + config.setAlgorithm("PBEWITHHMACSHA512ANDAES_256"); + config.setKeyObtentionIterations("1000"); // 반복할 해싱 회수 + config.setPoolSize("1"); + config.setProviderName("SunJCE"); + config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator"); + config.setIvGeneratorClassName("org.jasypt.iv.RandomIvGenerator"); + config.setStringOutputType("base64"); + encryptor.setConfig(config); + + return encryptor; + } +} diff --git a/src/main/java/com/minizin/travel/config/JpaAuditingConfiguration.java b/src/main/java/com/minizin/travel/config/JpaAuditingConfiguration.java new file mode 100644 index 0000000..9260aa0 --- /dev/null +++ b/src/main/java/com/minizin/travel/config/JpaAuditingConfiguration.java @@ -0,0 +1,10 @@ +package com.minizin.travel.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfiguration { + +} diff --git a/src/main/java/com/minizin/travel/config/MailConfig.java b/src/main/java/com/minizin/travel/config/MailConfig.java new file mode 100644 index 0000000..bf01e4d --- /dev/null +++ b/src/main/java/com/minizin/travel/config/MailConfig.java @@ -0,0 +1,51 @@ +package com.minizin.travel.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +public class MailConfig { + + @Value("${spring.mail.host}") + private String host; + + @Value("${spring.mail.port}") + private int port; + + @Value("${spring.mail.username}") + private String username; + + @Value("${spring.mail.password}") + private String password; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(host); + mailSender.setPort(port); + mailSender.setUsername(username); + mailSender.setPassword(password); + mailSender.setJavaMailProperties(this.getMailProperties()); + mailSender.setDefaultEncoding("UTF-8"); + + return mailSender; + } + + private Properties getMailProperties() { + Properties properties = new Properties(); + + properties.setProperty("mail.transport.protocol", "smtp"); + properties.setProperty("mail.smtp.auth", "true"); + properties.setProperty("mail.smtp.starttls.enable", "true"); + properties.setProperty("mail.debug", "true"); + properties.setProperty("mail.smtp.ssl.trust", "smtp.naver.com"); + properties.setProperty("mail.smtp.ssl.enable", "true"); + + return properties; + } +} diff --git a/src/main/java/com/minizin/travel/config/OAuth2Config.java b/src/main/java/com/minizin/travel/config/OAuth2Config.java new file mode 100644 index 0000000..b61ce23 --- /dev/null +++ b/src/main/java/com/minizin/travel/config/OAuth2Config.java @@ -0,0 +1,13 @@ +package com.minizin.travel.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; + +@Configuration +public class OAuth2Config { + @Bean + public DefaultOAuth2UserService defaultOAuth2UserService(){ + return new DefaultOAuth2UserService(); + } +} diff --git a/src/main/java/com/minizin/travel/config/RedisConfig.java b/src/main/java/com/minizin/travel/config/RedisConfig.java new file mode 100644 index 0000000..892abcb --- /dev/null +++ b/src/main/java/com/minizin/travel/config/RedisConfig.java @@ -0,0 +1,28 @@ +package com.minizin.travel.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.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public StringRedisTemplate stringRedisTemplate() { + return new StringRedisTemplate(this.redisConnectionFactory()); + } +} diff --git a/src/main/java/com/minizin/travel/config/SecurityConfig.java b/src/main/java/com/minizin/travel/config/SecurityConfig.java new file mode 100644 index 0000000..8ea4bf1 --- /dev/null +++ b/src/main/java/com/minizin/travel/config/SecurityConfig.java @@ -0,0 +1,145 @@ +package com.minizin.travel.config; + +import com.minizin.travel.user.auth.LoginFilter; +import com.minizin.travel.user.jwt.JwtAuthenticationFilter; +import com.minizin.travel.user.jwt.JwtExceptionFilter; +import com.minizin.travel.user.jwt.TokenProvider; +import com.minizin.travel.user.oauth2.CustomSuccessHandler; +import com.minizin.travel.user.service.CustomOAuth2UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsUtils; + +import java.util.Collections; +import java.util.List; + +@Slf4j +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final AuthenticationConfiguration authenticationConfiguration; + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomSuccessHandler customSuccessHandler; + private final TokenProvider tokenProvider; + private final JwtExceptionFilter jwtExceptionFilter; + + // BCryptPasswordEncoder Bean 등록 + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + log.debug("Creating BCryptPasswordEncoder bean"); + return new BCryptPasswordEncoder(); + } + + // AuthenticationManager Bean 등록 + @Bean + @Lazy + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + log.debug("Creating AuthenticationManager bean"); + return configuration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + log.debug("Configuring SecurityFilterChain"); + http + .cors(corsCustomizer -> corsCustomizer.configurationSource( + (request -> { + log.debug("Setting CORS configuration"); + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(List.of("http://localhost:3000", "https://fe-two-blond.vercel.app")); + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + + configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); + configuration.setExposedHeaders(Collections.singletonList("Authorization")); + + return configuration; + }) + )); + + //csrf disable + http + .csrf((csrf) -> { + log.debug("Disabling CSRF"); + csrf.disable(); + }); + + //From 로그인 방식 disable + http + .formLogin((formLogin) -> { + log.debug("Disabling formLogin"); + formLogin.disable(); + }); + + //HTTP Basic 인증 방식 disable + http + .httpBasic((httpBasic) -> { + log.debug("Disabling httpBasic"); + httpBasic.disable(); + }); + + + //oauth2 + http + .oauth2Login((oauth2) -> { + log.debug("Configuring OAuth2 login"); + oauth2.userInfoEndpoint((userInfoEndpointConfig) -> + userInfoEndpointConfig.userService(customOAuth2UserService)) + .successHandler(customSuccessHandler); + }); + + //경로별 인가 작업 + http + .authorizeHttpRequests((auth) -> { + log.debug("Configuring URL authorization"); + auth.requestMatchers(CorsUtils::isPreFlightRequest).permitAll(); + auth.requestMatchers("/", "/auth/**", "/mails/auth-code/**", + "/users/find-id", "/users/find-password", "/tour/**", + "plans/others/**", "plans/popular/week", + "/swagger-ui/**", "/v3/api-docs/**") + .permitAll() + .anyRequest().authenticated(); + }); + + // UsernamePasswordAuthenticationFilter 자리에 LoginFilter 추가 + http + .addFilterAt(new LoginFilter( + authenticationManager(authenticationConfiguration), tokenProvider + ), UsernamePasswordAuthenticationFilter.class); + + //JWT Authentication Filter 추가 + http + .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), + LoginFilter.class); + //JWT 예외 핸들러 filter 등록 + http + .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); + + //세션 설정 : STATELESS + http + .sessionManagement((session) -> { + log.debug("Setting session management to stateless"); + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS); + }); + + return http.build(); + } + +} diff --git a/src/main/java/com/minizin/travel/config/SwaggerConfig.java b/src/main/java/com/minizin/travel/config/SwaggerConfig.java new file mode 100644 index 0000000..3e3ee09 --- /dev/null +++ b/src/main/java/com/minizin/travel/config/SwaggerConfig.java @@ -0,0 +1,35 @@ +package com.minizin.travel.config; + +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Class: SwaggerConfig Project: travel Package: com.minizin.travel.config + *

+ * Description: SwaggerConfig + * + * @author dong-hoshin + * @date 6/25/24 21:54 Copyright (c) 2024 MiniJin + * @see GitHub Repository + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI api() { + return new OpenAPI() + .info(new Info() + .title("travel API Document") + .version("1.0") + .description("travel Service Backend API Document")) + .servers(List.of( + new Server().url("http://lyckabc.synology.me:20280"), + new Server().url("https://lyckabc.synology.me:23080") + )); + } + +} diff --git a/src/main/java/com/minizin/travel/global/GlobalExceptionHandler.java b/src/main/java/com/minizin/travel/global/GlobalExceptionHandler.java new file mode 100644 index 0000000..9801fbf --- /dev/null +++ b/src/main/java/com/minizin/travel/global/GlobalExceptionHandler.java @@ -0,0 +1,99 @@ +package com.minizin.travel.global; + +import com.minizin.travel.global.enums.ValidationErrorCode; +import com.minizin.travel.user.domain.dto.ErrorResponse; +import com.minizin.travel.user.domain.exception.CustomMailException; +import com.minizin.travel.user.domain.exception.CustomUserException; +import org.hibernate.exception.ConstraintViolationException; +import org.hibernate.exception.JDBCConnectionException; +import org.hibernate.exception.SQLGrammarException; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(CustomUserException.class) + public ResponseEntity handleCustomUserException(CustomUserException e) { + return ResponseEntity.status(e.getUserErrorCode().getStatus()) + .body(new ErrorResponse(e.getUserErrorCode().getStatus(), + e.getUserErrorCode().getMessage()) + ); + } + + @ExceptionHandler(CustomMailException.class) + public ResponseEntity handleCustomMailException(CustomMailException e) { + return ResponseEntity.status(e.getMailErrorCode().getStatus()) + .body(new ErrorResponse(e.getMailErrorCode().getStatus(), + e.getMailErrorCode().getMessage()) + ); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + List errors = e.getBindingResult().getFieldErrors() + .stream().map(error -> new ValidationErrorResponse.FieldError( + error.getField(), + error.getRejectedValue(), + error.getDefaultMessage())) + .toList(); + + return ResponseEntity.badRequest() + .body(new ValidationErrorResponse(ValidationErrorCode.INVALID_REQUEST.getStatus(), + ValidationErrorCode.INVALID_REQUEST.getMessage(), errors)); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException ex) { + ex.printStackTrace(); + return new ResponseEntity<>("Data integrity violation: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(DataAccessException.class) + public ResponseEntity handleDataAccessException(DataAccessException ex) { + ex.printStackTrace(); + return new ResponseEntity<>("Database error occurred: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(JDBCConnectionException.class) + public ResponseEntity handleJDBCConnectionException(JDBCConnectionException ex) { + ex.printStackTrace(); + return new ResponseEntity<>("Database connection error: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(SQLGrammarException.class) + public ResponseEntity handleSQLGrammarException(SQLGrammarException ex) { + ex.printStackTrace(); + return new ResponseEntity<>("SQL syntax error: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException ex) { + ex.printStackTrace(); + return new ResponseEntity<>("Database constraint violation: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(NullPointerException.class) + public ResponseEntity handleNullPointerException(NullPointerException ex) { + ex.printStackTrace(); + return new ResponseEntity<>("Unexpected error occurred: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { + ex.printStackTrace(); + return new ResponseEntity<>("Invalid argument: " + ex.getMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + ex.printStackTrace(); + return new ResponseEntity<>("An error occurred: " + ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/com/minizin/travel/global/ValidationErrorResponse.java b/src/main/java/com/minizin/travel/global/ValidationErrorResponse.java new file mode 100644 index 0000000..0e5c0d1 --- /dev/null +++ b/src/main/java/com/minizin/travel/global/ValidationErrorResponse.java @@ -0,0 +1,23 @@ +package com.minizin.travel.global; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class ValidationErrorResponse { + HttpStatus status; + String message; + List fieldErrors; + + @Getter + @AllArgsConstructor + public static class FieldError { + private String field; + private Object rejectedValue; + private String reason; + } +} diff --git a/src/main/java/com/minizin/travel/global/enums/ValidationErrorCode.java b/src/main/java/com/minizin/travel/global/enums/ValidationErrorCode.java new file mode 100644 index 0000000..3873446 --- /dev/null +++ b/src/main/java/com/minizin/travel/global/enums/ValidationErrorCode.java @@ -0,0 +1,15 @@ +package com.minizin.travel.global.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ValidationErrorCode { + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "유효성 검사에 실패했습니다."), + ; + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/minizin/travel/like/controller/LikeController.java b/src/main/java/com/minizin/travel/like/controller/LikeController.java new file mode 100644 index 0000000..215a94e --- /dev/null +++ b/src/main/java/com/minizin/travel/like/controller/LikeController.java @@ -0,0 +1,46 @@ +/* +package com.minizin.travel.like.controller; + +import com.minizin.travel.like.service.LikeService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class LikeController { + + private final LikeService likeService; + + // #52 2024.06.13 좋아요 생성 START // + @PostMapping("/likes/{plan_id}") + public ResponseEntity createLikePlan(@PathVariable("plan_id") Long planId) { + + var result = likeService.createLikePlan(planId); + + return ResponseEntity.ok(result); + } + // #52 2024.06.13 좋아요 생성 END // + + // #53 2024.06.13 좋아요 조회 START // + @GetMapping("/likes/{cursor_id}") + public ResponseEntity selectListLikedPlans(@PathVariable("cursor_id") Long cursorId) { + + var result = likeService.selectListLikedPlans(cursorId); + + return ResponseEntity.ok(result); + } + // #53 2024.06.13 좋아요 조회 END // + + // #54 2024.06.14 좋아요 삭제 START // + @DeleteMapping("/likes/{like_id}") + public ResponseEntity deleteLikedPlan(@PathVariable("like_id") Long likeId) { + + var result = likeService.deleteLikedPlan(likeId); + + return ResponseEntity.ok(result); + } + // #54 2024.06.14 좋아요 삭제 END // + +} +*/ diff --git a/src/main/java/com/minizin/travel/like/dto/ResponseCreateLikePlanDto.java b/src/main/java/com/minizin/travel/like/dto/ResponseCreateLikePlanDto.java new file mode 100644 index 0000000..a5180a2 --- /dev/null +++ b/src/main/java/com/minizin/travel/like/dto/ResponseCreateLikePlanDto.java @@ -0,0 +1,34 @@ +package com.minizin.travel.like.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.like.entity.Likes; +import lombok.Builder; +import lombok.Getter; + +import java.time.format.DateTimeFormatter; + +@Builder +@Getter +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class ResponseCreateLikePlanDto { + + @JsonProperty("like_id") + private Long id; + + private Long userId; + + private Long planId; + + private String createdAt; + + public static ResponseCreateLikePlanDto toDto(Likes like) { + return ResponseCreateLikePlanDto.builder() + .id(like.getId()) + .userId(like.getUserId()) + .planId(like.getPlanId()) + .createdAt(like.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/like/dto/ResponseDeleteLikePlanDto.java b/src/main/java/com/minizin/travel/like/dto/ResponseDeleteLikePlanDto.java new file mode 100644 index 0000000..89da157 --- /dev/null +++ b/src/main/java/com/minizin/travel/like/dto/ResponseDeleteLikePlanDto.java @@ -0,0 +1,2 @@ +package com.minizin.travel.like.dto;public class ResponseDeleteLikePlanDto { +} diff --git a/src/main/java/com/minizin/travel/like/dto/ResponseSelectLikedPlansDto.java b/src/main/java/com/minizin/travel/like/dto/ResponseSelectLikedPlansDto.java new file mode 100644 index 0000000..f16f4f4 --- /dev/null +++ b/src/main/java/com/minizin/travel/like/dto/ResponseSelectLikedPlansDto.java @@ -0,0 +1,17 @@ +/*package com.minizin.travel.like.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class ResponseSelectLikedPlansDto { + + List data; + + private Long cursorId; + +} +*/ \ No newline at end of file diff --git a/src/main/java/com/minizin/travel/like/dto/SelectLikedPlansDto.java b/src/main/java/com/minizin/travel/like/dto/SelectLikedPlansDto.java new file mode 100644 index 0000000..0694f82 --- /dev/null +++ b/src/main/java/com/minizin/travel/like/dto/SelectLikedPlansDto.java @@ -0,0 +1,64 @@ +/* +package com.minizin.travel.like.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.Plan; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Builder +@Getter +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +@JsonPropertyOrder({"id", "planId", "userNickname", "planName", "theme", "startDate", "endDate", + "planBudget", "scope", "numberOfMembers", "numberOfLikes", "numberOfScraps"}) +public class SelectLikedPlansDto { + + @Setter + @JsonProperty("like_id") + private Long id; + + private Long planId; + + @Setter + private String userNickname; + + private String planName; + + private String theme; + + private LocalDate startDate; + + private LocalDate endDate; + + @Setter + private int planBudget; + + private boolean scope; + + private int numberOfMembers; + + private int numberOfLikes; + + private int numberOfScraps; + + public static SelectLikedPlansDto toDto(Plan plan) { + return SelectLikedPlansDto.builder() + .planId(plan.getId()) + .planName(plan.getPlanName()) + .theme(plan.getTheme()) + .startDate(plan.getStartDate()) + .endDate(plan.getEndDate()) + .scope(plan.isScope()) + .numberOfMembers(plan.getNumberOfMembers()) + .numberOfLikes(plan.getNumberOfLikes()) + .numberOfScraps(plan.getNumberOfScraps()) + .build(); + } +} +*/ diff --git a/src/main/java/com/minizin/travel/like/entity/Likes.java b/src/main/java/com/minizin/travel/like/entity/Likes.java new file mode 100644 index 0000000..4f93ed7 --- /dev/null +++ b/src/main/java/com/minizin/travel/like/entity/Likes.java @@ -0,0 +1,30 @@ +package com.minizin.travel.like.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Likes { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "like_id") + private Long id; + + private Long userId; + + private Long planId; + + @CreatedDate + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/minizin/travel/like/repository/LikeRepository.java b/src/main/java/com/minizin/travel/like/repository/LikeRepository.java new file mode 100644 index 0000000..d8df97c --- /dev/null +++ b/src/main/java/com/minizin/travel/like/repository/LikeRepository.java @@ -0,0 +1,23 @@ +package com.minizin.travel.like.repository; + +import com.minizin.travel.like.entity.Likes; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface LikeRepository extends JpaRepository { + + // #52 2024.06.13 좋아요 생성 // + Boolean existsByPlanIdAndUserId(Long planId, Long userId); + + // #53 2024.06.13 좋아요 조회 START // + List findAllByUserIdOrderByIdDesc(Long userId, Pageable page); + + List findByIdLessThanAndUserIdOrderByIdDesc(Long cursorId, Long userId, Pageable page); + + Boolean existsByUserId(Long userId); + + // #53 2024.06.13 좋아요 조회 END // + +} diff --git a/src/main/java/com/minizin/travel/like/service/LikeService.java b/src/main/java/com/minizin/travel/like/service/LikeService.java new file mode 100644 index 0000000..0a96d48 --- /dev/null +++ b/src/main/java/com/minizin/travel/like/service/LikeService.java @@ -0,0 +1,130 @@ +/* +package com.minizin.travel.like.service; + +import com.minizin.travel.like.dto.ResponseCreateLikePlanDto; +import com.minizin.travel.like.dto.ResponseDeleteLikePlanDto; +import com.minizin.travel.like.dto.ResponseSelectLikedPlansDto; +import com.minizin.travel.like.dto.SelectLikedPlansDto; +import com.minizin.travel.like.entity.Likes; +import com.minizin.travel.like.repository.LikeRepository; +import com.minizin.travel.plan.entity.Plan; +import com.minizin.travel.plan.entity.PlanSchedule; +import com.minizin.travel.plan.repository.PlanRepository; +import com.minizin.travel.plan.repository.PlanScheduleRepository; +import com.minizin.travel.plan.service.PlanService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + private final PlanRepository planRepository; + private final PlanScheduleRepository planScheduleRepository; + private final PlanService planService; + + final int DEFAULT_PAGE_SIZE = 6; + + // #52 2024.06.13 좋아요 생성 START // + public ResponseCreateLikePlanDto createLikePlan(Long planId) { + + // 테스트 + Long userId = 1L; + + if (!planRepository.existsById(planId)) { + throw new RuntimeException("유효하지 않은 plan id"); + } + + if (likeRepository.existsByPlanIdAndUserId(planId, userId)) { + throw new RuntimeException("이미 좋아요를 누른 plan"); + } + + Plan plan = planRepository.findById(planId).get(); + plan.setNumberOfLikes(plan.getNumberOfLikes() + 1); + + return ResponseCreateLikePlanDto.toDto(likeRepository.save(Likes.builder() + .userId(userId) + .planId(planId) + .createdAt(LocalDateTime.now()) + .build())); + } + // #52 2024.06.13 좋아요 생성 END // + + // #53 2024.06.13 좋아요 조회 START // + public ResponseSelectLikedPlansDto selectListLikedPlans(Long cursorId) { + + Pageable page = PageRequest.of(0, DEFAULT_PAGE_SIZE); + + // 테스트 + Long userId = 1L; + + if (!likeRepository.existsByUserId(userId)) { + throw new RuntimeException("좋아요한 plan이 없습니다."); + } + + List likeList = findAllByCursorIdCheckExistCursor(userId, cursorId, page); + List listLikedPlansDtoList = new ArrayList<>(); + Long likeId = 0L; + for (Likes like : likeList) { + likeId = like.getId(); + Plan plan = planRepository.findById(like.getPlanId()).get(); + + SelectLikedPlansDto newSelectLikedPlansDto = SelectLikedPlansDto.toDto(plan); + newSelectLikedPlansDto.setId(likeId); + // 닉네임 추가 필요 newLikedPlansDto.setUserNickname + + // 예산 계산 + List planScheduleList = planScheduleRepository.findAllByPlanId(plan.getId()); + newSelectLikedPlansDto.setPlanBudget(planService.calculateTotalPlanBudget(planScheduleList)); + + listLikedPlansDtoList.add(newSelectLikedPlansDto); + } + + return ResponseSelectLikedPlansDto.builder() + .data(listLikedPlansDtoList) + .cursorId(likeId) + .build(); + } + + private List findAllByCursorIdCheckExistCursor(Long userId, Long cursorId, Pageable page) { + + return cursorId == 0 ? likeRepository.findAllByUserIdOrderByIdDesc(userId, page) + : likeRepository.findByIdLessThanAndUserIdOrderByIdDesc(cursorId, userId, page); + } + // #53 2024.06.13 좋아요 조회 END // + + // #54 2024.06.14 좋아요 삭제 START // + public ResponseDeleteLikePlanDto deleteLikedPlan(Long likeId) { + + if (!likeRepository.existsById(likeId)) { + + return ResponseDeleteLikePlanDto.builder() + .success(false) + .message("존재하지 않는 like_id 입니다.") + .likeId(likeId) + .build(); + } + + Long planId = likeRepository.findById(likeId).get().getPlanId(); + Plan plan = planRepository.findById(planId).get(); + plan.setNumberOfLikes(plan.getNumberOfLikes() - 1); + + likeRepository.deleteById(likeId); + + return ResponseDeleteLikePlanDto.builder() + .success(true) + .message("Like Deleted Successfully") + .likeId(likeId) + .build(); + } + // #54 2024.06.14 좋아요 삭제 END // + +} +*/ diff --git a/src/main/java/com/minizin/travel/plan/controller/OtherPlanController.java b/src/main/java/com/minizin/travel/plan/controller/OtherPlanController.java new file mode 100644 index 0000000..b757bae --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/controller/OtherPlanController.java @@ -0,0 +1,64 @@ +package com.minizin.travel.plan.controller; + +import com.minizin.travel.plan.service.OtherPlanService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class OtherPlanController { + + private final OtherPlanService otherPlanService; + + + // #48 2024.06.10 다른 사람 여행 일정 조회 START // + @GetMapping("/plans/others/newest") + public ResponseEntity selectOthersListPlan(@RequestParam("cursor_id") Long lastPlanId, + @RequestParam("region") String region, + @RequestParam("theme") String theme, + @RequestParam("search") String search) { + + var result = otherPlanService.selectOthersListPlan(lastPlanId, region, theme, search); + + return ResponseEntity.ok(result); + } + // #48 2024.06.10 다른 사람 여행 일정 조회 END // + + // #58 2024.06.12 다른 사람 여행 일정 조회(북마크순) START // + @GetMapping("/plans/others/scraps") + public ResponseEntity selectOthersListPlanScraps(@RequestParam("cursor_id") Long lastPlanId, + @RequestParam("region") String region, + @RequestParam("theme") String theme, + @RequestParam("search") String search) { + + var result = otherPlanService.selectOthersListPlanScraps(lastPlanId, region, theme, search); + + return ResponseEntity.ok(result); + } + // #58 2024.06.12 다른 사람 여행 일정 조회(북마크순) END // + + // #106 2024.06.16 금주 인기 여행 일정 조회(북마크순) START // + @GetMapping("/plans/popular/week") + public ResponseEntity selectPopularPlansWeek() { + + var result = otherPlanService.selectPopularPlansWeek(); + + return ResponseEntity.ok(result); + } + // #106 2024.06.16 금주 인기 여행 일정 조회(북마크순) END // + + // #129 다른 사람의 여행 일정 상세 보기 START // + @GetMapping("/plans/others/{plan_id}") + public ResponseEntity selectOtherDetailPlan(@PathVariable("plan_id") Long planId) { + + var result = otherPlanService.selectOtherDetailPlan(planId); + + return ResponseEntity.ok(result); + } + // #129 다른 사람의 여행 일정 상세 보기 END // +} + diff --git a/src/main/java/com/minizin/travel/plan/controller/PlanController.java b/src/main/java/com/minizin/travel/plan/controller/PlanController.java index 7cd05c7..31da38e 100644 --- a/src/main/java/com/minizin/travel/plan/controller/PlanController.java +++ b/src/main/java/com/minizin/travel/plan/controller/PlanController.java @@ -1,10 +1,14 @@ package com.minizin.travel.plan.controller; -import com.fasterxml.jackson.core.JsonProcessingException; +import com.minizin.travel.plan.dto.EditPlanDto; import com.minizin.travel.plan.dto.PlanDto; import com.minizin.travel.plan.service.PlanService; +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.apache.coyote.BadRequestException; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -16,10 +20,11 @@ public class PlanController { // #28 2024.05.30 내 여행 일정 생성하기 START // @PostMapping("/plans") public ResponseEntity createPlan( - @RequestBody PlanDto request - ) throws JsonProcessingException { + @RequestBody @Valid PlanDto request, // @Valid : #87 Request 예외/에러 처리 + @AuthenticationPrincipal PrincipalDetails user) + throws BadRequestException { - var result = planService.createPlan(request); + var result = planService.createPlan(request, user); return ResponseEntity.ok(result); @@ -28,12 +33,68 @@ public ResponseEntity createPlan( // #29 2024.06.02 내 여행 일정 조회 START // @GetMapping("/plans") - public ResponseEntity selectListPlan(@RequestParam("cursor_id") Long cursorId) { + public ResponseEntity selectListPlan(@RequestParam("cursor_id") Long lastPlanId, + @AuthenticationPrincipal PrincipalDetails user) { // #102 [GET] /plans : Refactoring - cursorId renaming - var result = planService.selectListPlan(cursorId); + var result = planService.selectListPlan(lastPlanId, user); return ResponseEntity.ok(result); } // #29 2024.06.02 내 여행 일정 조회 END // + + // #32 2024.06.07 내 여행 일정 수정 START // + @PutMapping("/plans/{plan_id}") + public ResponseEntity updatePlan(@PathVariable("plan_id") Long planId, + @RequestBody EditPlanDto request, + @AuthenticationPrincipal PrincipalDetails user) { + + var result = planService.updatePlan(planId, request, user); + + return ResponseEntity.ok(result); + } + // #32 2024.06.07 내 여행 일정 수정 END // + + // #38 2024.06.08 내 여행 일정 상세 보기 START // + @GetMapping("/plans/details/{plan_id}") + public ResponseEntity selectDetailPlan(@PathVariable("plan_id") Long planId, + @AuthenticationPrincipal PrincipalDetails user) { + + var result = planService.selectDetailPlan(planId, user); + + return ResponseEntity.ok(result); + } + // #38 2024.06.08 내 여행 일정 상세 보기 END // + + // #47 2024.06.13 내 여행 일정 삭제 START // + @DeleteMapping("/plans/{plan_id}") + public ResponseEntity deletePlan(@PathVariable("plan_id") Long planId, + @AuthenticationPrincipal PrincipalDetails user) { + + var result = planService.deletePlan(planId, user); + + return ResponseEntity.ok(result); + } + // #47 2024.06.13 내 여행 일정 삭제 END // + + // #39 2024.06.10 다가오는 여행 일정 조회 START // + @GetMapping("/plans/upcoming") + public ResponseEntity selectUpcomingPlan(@AuthenticationPrincipal PrincipalDetails user) { + + var result = planService.selectUpcomingPlan(user); + + return ResponseEntity.ok(result); + } + // #39 2024.06.10 다가오는 여행 일정 조회 END // + + // #107 2024.06.20 일정 복사하기 START // + @GetMapping("/plans/copy/{plan_id}") + public ResponseEntity copyAndCreatePlan(@PathVariable("plan_id") Long planId, + @AuthenticationPrincipal PrincipalDetails user) { + + var result = planService.copyAndCreatePlan(planId, user); + + return ResponseEntity.ok(result); + } + // #107 2024.06.20 일정 복사하기 END // } diff --git a/src/main/java/com/minizin/travel/plan/dto/DetailPlanBudgetDto.java b/src/main/java/com/minizin/travel/plan/dto/DetailPlanBudgetDto.java new file mode 100644 index 0000000..85abbdd --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/DetailPlanBudgetDto.java @@ -0,0 +1,31 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.PlanBudget; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@JsonPropertyOrder({"id", "budgetCategory", "cost", "budgetMemo"}) +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class DetailPlanBudgetDto { + + @JsonProperty("budget_id") + private Long id; + + private String budgetCategory; + + private Integer cost; + + public static DetailPlanBudgetDto toDto(PlanBudget planBudget) { + return DetailPlanBudgetDto.builder() + .id(planBudget.getId()) + .budgetCategory(planBudget.getBudgetCategory()) + .cost(planBudget.getCost()) + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/plan/dto/DetailPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/DetailPlanDto.java new file mode 100644 index 0000000..b95e925 --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/DetailPlanDto.java @@ -0,0 +1,91 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.Plan; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +@Builder +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({"id", "userId", "planName", "theme", "startDate", "endDate", "planBudget", "scope", "numberOfMembers", + "numberOfScraps", "regionList", "detailPlanScheduleDtoList"}) +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class DetailPlanDto { + + @JsonProperty("plan_id") + private Long id; + + private Long userId; + + private String planName; + + @Setter + private String userNickname; + + private String theme; + + private LocalDate startDate; + + private LocalDate endDate; + + @Setter + private Integer planBudget; + + private boolean scope; + + private Integer numberOfMembers; + + private Integer numberOfScraps; + + @Setter + private List regionList; + + @Setter + @JsonProperty("schedules") + private List detailPlanScheduleDtoList; + + private Integer error; + private String message; + + public static DetailPlanDto toDto(Plan plan) { + return DetailPlanDto.builder() + .id(plan.getId()) + .userId(plan.getUserId()) + .planName(plan.getPlanName()) + .theme(plan.getTheme()) + .startDate(plan.getStartDate()) + .endDate(plan.getEndDate()) + .scope(plan.isScope()) + .numberOfMembers(plan.getNumberOfMembers()) + .numberOfScraps(plan.getNumberOfScraps()) + .build(); + } + + public static DetailPlanDto existsNot(Long planId) { + + return DetailPlanDto.builder() + .error(-1) + .id(planId) + .message("요청하신 plan 은 존재하지 않습니다.") + .build(); + } + + public static DetailPlanDto notAuth(Long planId, String message) { + + return DetailPlanDto.builder() + .error(-2) + .id(planId) + .message(message) + .build(); + } + +} diff --git a/src/main/java/com/minizin/travel/plan/dto/DetailPlanScheduleDto.java b/src/main/java/com/minizin/travel/plan/dto/DetailPlanScheduleDto.java new file mode 100644 index 0000000..ae1da7c --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/DetailPlanScheduleDto.java @@ -0,0 +1,64 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.PlanSchedule; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Builder +@Getter +@JsonPropertyOrder({"id", "scheduleDays", "scheduleDate", "placeCategory", "placeName", "region", "placeMemo", "arrivalTime", "detailPlanBudgetDtoList", "x", "y", "placeAddr"}) +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class DetailPlanScheduleDto { + + @JsonProperty("schedule_id") + private Long id; + + @Setter + private int scheduleDays; + + private LocalDate scheduleDate; + + private String placeName; + + private String region; + + private String placeMemo; + + private String arrivalTime; + + private Double x; + private Double y; + + private String placeAddr; + + private String placeCategory; + + @Setter + @JsonProperty("budgets") + List detailPlanBudgetDtoList; + + public static DetailPlanScheduleDto toDto(PlanSchedule planSchedule) { + return DetailPlanScheduleDto.builder() + .id(planSchedule.getId()) + .scheduleDate(planSchedule.getScheduleDate()) + .placeName(planSchedule.getPlaceName()) + .region(planSchedule.getRegion()) + .placeMemo(planSchedule.getPlaceMemo()) + .arrivalTime(planSchedule.getArrivalTime().format(DateTimeFormatter.ofPattern("HH:mm"))) + .x(planSchedule.getX()) + .y(planSchedule.getY()) + .placeAddr(planSchedule.getPlaceAddr()) + .placeCategory(planSchedule.getPlaceCategory()) + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/plan/dto/EditPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/EditPlanDto.java new file mode 100644 index 0000000..2153b4c --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/EditPlanDto.java @@ -0,0 +1,43 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +@JsonPropertyOrder({"id", "userId", "planName", "theme", "startDate", "endDate" + , "scope", "numberOfMembers", "numberOfScraps", "schedules"}) +public class EditPlanDto { + + private Long userId; + + private String planName; + + private String theme; + + private String startDate; + + private String endDate; + + private boolean scope; + + private int numberOfMembers; + + private int numberOfScraps; + + @JsonProperty("schedules") + private List planScheduleDtos; + +} diff --git a/src/main/java/com/minizin/travel/plan/dto/OthersListPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/OthersListPlanDto.java new file mode 100644 index 0000000..cb3bbf5 --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/OthersListPlanDto.java @@ -0,0 +1,67 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.Plan; +import lombok.*; + +import java.time.LocalDate; +import java.util.List; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@JsonPropertyOrder({"id", "userId", "userNickname", "planName", "theme", "startDate", "endDate" + , "planBudget", "scope", "numberOfMembers", "numberOfScraps", "regionList", "othersListPlanScheduleDtoList"}) +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class OthersListPlanDto { + + @JsonProperty("plan_id") + private Long id; + + private Long userId; + + @Setter + private String userNickname; + + private String planName; + + private String theme; + + private LocalDate startDate; + + private LocalDate endDate; + + @Setter + private int planBudget; + + private boolean scope; + + private int numberOfMembers; + + private int numberOfScraps; + + @Setter + List regionList; + + @Setter + @JsonProperty("schedules") + List othersListPlanScheduleDtoList; + + public static OthersListPlanDto toDto(Plan plan) { + return OthersListPlanDto.builder() + .id(plan.getId()) + .userId(plan.getUserId()) + .planName(plan.getPlanName()) + .theme(plan.getTheme()) + .startDate(plan.getStartDate()) + .endDate(plan.getEndDate()) + .scope(plan.isScope()) + .numberOfMembers(plan.getNumberOfMembers()) + .numberOfScraps(plan.getNumberOfScraps()) + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/plan/dto/OthersListPlanScheduleDto.java b/src/main/java/com/minizin/travel/plan/dto/OthersListPlanScheduleDto.java new file mode 100644 index 0000000..003b92f --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/OthersListPlanScheduleDto.java @@ -0,0 +1,53 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.PlanSchedule; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +@Builder +@Getter +@JsonPropertyOrder({"id", "scheduleDate", "placeName", "region", "arrivalTime", "x", "y" + , "placeAddr", "placeCategory"}) +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class OthersListPlanScheduleDto { + + @JsonProperty("schedule_id") + private Long id; + + private LocalDate scheduleDate; + + private String placeName; + + private String region; + + private String arrivalTime; + + private Double x; + private Double y; + + private String placeAddr; + + private String placeCategory; + + public static OthersListPlanScheduleDto toDto(PlanSchedule planSchedule) { + return OthersListPlanScheduleDto.builder() + .id(planSchedule.getId()) + .scheduleDate(planSchedule.getScheduleDate()) + .placeName(planSchedule.getPlaceName()) + .region(planSchedule.getRegion()) + .arrivalTime(planSchedule.getArrivalTime().format(DateTimeFormatter.ofPattern("HH:mm"))) + .x(planSchedule.getX()) + .y(planSchedule.getY()) + .placeAddr(planSchedule.getPlaceAddr()) + .placeCategory(planSchedule.getPlaceCategory()) + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/plan/dto/PlanBudgetDto.java b/src/main/java/com/minizin/travel/plan/dto/PlanBudgetDto.java index ea31144..f06705c 100644 --- a/src/main/java/com/minizin/travel/plan/dto/PlanBudgetDto.java +++ b/src/main/java/com/minizin/travel/plan/dto/PlanBudgetDto.java @@ -2,9 +2,10 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.PlanBudget; import lombok.*; -@Data +@Getter @Builder @NoArgsConstructor @AllArgsConstructor @@ -15,6 +16,10 @@ public class PlanBudgetDto { private int cost; - private String budgetMemo; - + public static PlanBudgetDto toDto(PlanBudget budget) { + return PlanBudgetDto.builder() + .budgetCategory(budget.getBudgetCategory()) + .cost(budget.getCost()) + .build(); + } } diff --git a/src/main/java/com/minizin/travel/plan/dto/PlanDto.java b/src/main/java/com/minizin/travel/plan/dto/PlanDto.java index 0189dda..5519fd0 100644 --- a/src/main/java/com/minizin/travel/plan/dto/PlanDto.java +++ b/src/main/java/com/minizin/travel/plan/dto/PlanDto.java @@ -4,40 +4,40 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.PropertyNamingStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import jakarta.validation.constraints.Size; import lombok.*; +import org.hibernate.validator.constraints.Range; import java.time.LocalDate; import java.util.List; @Getter @Builder -@AllArgsConstructor @NoArgsConstructor +@AllArgsConstructor @JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) -@JsonPropertyOrder({"id", "userId", "planName", "theme", "startDate", "endDate", "scope", "numberOfMembers", "numberOfLikes", "numberOfScraps", "waypoints", "scheduleDtos"}) +@JsonPropertyOrder({"userId", "planName", "theme", "startDate", "endDate", "scope", "numberOfMembers", "schedules"}) public class PlanDto { private Long userId; + // #87 Request 예외/에러 처리 + @Size(min = 2, max = 60, message = "'여행 일정 이름'은 2 ~ 60자여야 합니다.") private String planName; private String theme; - private LocalDate startDate; + private String startDate; - private LocalDate endDate; + private String endDate; private boolean scope; + // #87 Request 예외/에러 처리 + @Range(min = 1, max = 300, message = "'여행 인원'은 1 ~ 20명이어야 합니다.") private int numberOfMembers; - //private int numberOfLikes; - - //private int numberOfScraps; - - //private List waypoints; - - @JsonProperty("schedule") + @JsonProperty("schedules") private List planScheduleDtos; } \ No newline at end of file diff --git a/src/main/java/com/minizin/travel/plan/dto/PlanScheduleDto.java b/src/main/java/com/minizin/travel/plan/dto/PlanScheduleDto.java index 53cc86e..5634c54 100644 --- a/src/main/java/com/minizin/travel/plan/dto/PlanScheduleDto.java +++ b/src/main/java/com/minizin/travel/plan/dto/PlanScheduleDto.java @@ -3,31 +3,37 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.PropertyNamingStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.PlanSchedule; +import jakarta.validation.constraints.Size; import lombok.*; import java.time.LocalDate; import java.util.List; -@Data +@Getter @Builder @NoArgsConstructor @AllArgsConstructor @JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) public class PlanScheduleDto { - private LocalDate scheduleDate; + @Setter + private String scheduleDate; // 직렬화, 역직렬화 문제로 수정 private String placeCategory; + @Size(min = 1, max = 40, message = "'장소 이름'의 길이는 1 ~ 40자입니다.") private String placeName; private String region; + @Size(max = 100, message = "'메모'의 길이는 최대 100자입니다.") private String placeMemo; private String arrivalTime; - @JsonProperty("budget") + @JsonProperty("budgets") + @Setter private List planBudgetDtos; private Double x; @@ -35,5 +41,19 @@ public class PlanScheduleDto { // #29 2024.06.02 내 여행 일정 조회 private String placeAddr; + + public static PlanScheduleDto toDto(PlanSchedule planSchedule) { + return PlanScheduleDto.builder() + .scheduleDate(String.valueOf(planSchedule.getScheduleDate())) + .placeCategory(planSchedule.getPlaceCategory()) + .placeName(planSchedule.getPlaceName()) + .region(planSchedule.getRegion()) + .placeMemo(planSchedule.getPlaceMemo()) + .arrivalTime(String.valueOf(planSchedule.getArrivalTime())) + .x(planSchedule.getX()) + .y(planSchedule.getY()) + .placeAddr(planSchedule.getPlaceAddr()) + .build(); + } } diff --git a/src/main/java/com/minizin/travel/plan/dto/PopWeekPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/PopWeekPlanDto.java new file mode 100644 index 0000000..9868220 --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/PopWeekPlanDto.java @@ -0,0 +1,58 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.Plan; +import lombok.*; + +import java.time.LocalDate; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@JsonPropertyOrder({"id", "planName","userId", "userNickname", "theme", "startDate" + ,"endDate", "startDate", "endDate", "planBudget", "numberOfMembers", "numberOfScraps"}) +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class PopWeekPlanDto { + + @JsonProperty("plan_id") + private Long id; + + private String planName; + + private Long userId; + + @Setter + private String userNickname; + + private String theme; + + private LocalDate startDate; + + private LocalDate endDate; + + @Setter + private int planBudget; + + private int numberOfMembers; + + private int numberOfScraps; + + public static PopWeekPlanDto toDto(Plan plan) { + + return PopWeekPlanDto.builder() + .id(plan.getId()) + .planName(plan.getPlanName()) + .userId(plan.getUserId()) + .theme(plan.getTheme()) + .startDate(plan.getStartDate()) + .endDate(plan.getEndDate()) + .numberOfMembers(plan.getNumberOfMembers()) + .numberOfScraps(plan.getNumberOfScraps()) + .build(); + } + +} diff --git a/src/main/java/com/minizin/travel/plan/dto/ResponseDeletePlanDto.java b/src/main/java/com/minizin/travel/plan/dto/ResponseDeletePlanDto.java new file mode 100644 index 0000000..ea2e89f --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/ResponseDeletePlanDto.java @@ -0,0 +1,19 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class ResponseDeletePlanDto { + + private boolean success; + + private String message; + + private Long planId; + +} diff --git a/src/main/java/com/minizin/travel/plan/dto/ResponseEditPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/ResponseEditPlanDto.java new file mode 100644 index 0000000..7feb266 --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/ResponseEditPlanDto.java @@ -0,0 +1,29 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class ResponseEditPlanDto { + + boolean success; + String message; + + Long planId; + + Integer numberOfScraps; + + String createdAt; + + String updatedAt; +} diff --git a/src/main/java/com/minizin/travel/plan/dto/ResponseListPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/ResponseListPlanDto.java deleted file mode 100644 index da914e8..0000000 --- a/src/main/java/com/minizin/travel/plan/dto/ResponseListPlanDto.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.minizin.travel.plan.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; - -// #29 2024.06.02 내 여행 일정 조회 START // -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ResponseListPlanDto { - - List data; - - Long nextCursor; -} -// #29 2024.06.02 내 여행 일정 조회 END // \ No newline at end of file diff --git a/src/main/java/com/minizin/travel/plan/dto/ResponseOthersPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/ResponseOthersPlanDto.java new file mode 100644 index 0000000..db57161 --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/ResponseOthersPlanDto.java @@ -0,0 +1,26 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +// #48 2024.06.10 다른 사람 여행 일정 조회 START // +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class ResponseOthersPlanDto { + + List data; + + Long nextCursor; +} +// #48 2024.06.10 다른 사람 여행 일정 조회 END // \ No newline at end of file diff --git a/src/main/java/com/minizin/travel/plan/dto/ResponsePlanDto.java b/src/main/java/com/minizin/travel/plan/dto/ResponsePlanDto.java index 36cdd0c..d64853a 100644 --- a/src/main/java/com/minizin/travel/plan/dto/ResponsePlanDto.java +++ b/src/main/java/com/minizin/travel/plan/dto/ResponsePlanDto.java @@ -1,29 +1,85 @@ package com.minizin.travel.plan.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.PropertyNamingStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import com.minizin.travel.plan.entity.Plan; +import lombok.*; -@Data +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Getter @Builder @NoArgsConstructor @AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) @JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) public class ResponsePlanDto { - boolean success; - String message; + private boolean success; + private String message; + + private Long planId; + + private Integer numberOfScraps; // #87 Request 예외/에러 처리 + + private String createAt; + + private String updatedAt; + + private Data data; + + @Builder + @Getter + @JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) + public static class Data { + + private String startDate; + + private String endDate; + + private String scheduleDate; + } + + public static ResponsePlanDto success(Plan plan) { + + return ResponsePlanDto.builder() + .success(true) + .message("일정을 생성하였습니다.") + .planId(plan.getId()) + .numberOfScraps(plan.getNumberOfScraps()) + .createAt(plan.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .updatedAt(plan.getModifiedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build(); + } + + public static ResponsePlanDto fail(ResponsePlanDto.Data data) { - Long planId; + return ResponsePlanDto.builder() + .success(false) + .message("날짜가 유효하지 않습니다.") + .data(data).build(); + } - int numberOfLikes; + public static ResponsePlanDto copySuccess(Plan plan) { - int numberOfScraps; + return ResponsePlanDto.builder() + .success(true) + .message("일정을 복사하였습니다.") + .planId(plan.getId()) + .numberOfScraps(plan.getNumberOfScraps()) + .createAt(plan.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .updatedAt(plan.getModifiedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build(); + } - String createAt; + public static ResponsePlanDto existsNot(Long planId) { - String updatedAt; + return ResponsePlanDto.builder() + .success(false) + .message("요청하신 plan 은 존재하지 않습니다.") + .planId(planId) + .build(); + } } diff --git a/src/main/java/com/minizin/travel/plan/dto/ResponseSelectListPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/ResponseSelectListPlanDto.java new file mode 100644 index 0000000..54f24cb --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/ResponseSelectListPlanDto.java @@ -0,0 +1,36 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +// #29 2024.06.02 내 여행 일정 조회 START // + +@Getter +@Builder +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponseSelectListPlanDto { + + // success + private List data; + + private Long nextCursor; + + // fail + private Boolean success; + + private String message; + + private Long userId; + +} +// #29 2024.06.02 내 여행 일정 조회 END // \ No newline at end of file diff --git a/src/main/java/com/minizin/travel/plan/dto/ListPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/SelectListPlanDto.java similarity index 75% rename from src/main/java/com/minizin/travel/plan/dto/ListPlanDto.java rename to src/main/java/com/minizin/travel/plan/dto/SelectListPlanDto.java index 4b5913a..dc581e1 100644 --- a/src/main/java/com/minizin/travel/plan/dto/ListPlanDto.java +++ b/src/main/java/com/minizin/travel/plan/dto/SelectListPlanDto.java @@ -15,8 +15,9 @@ @AllArgsConstructor @NoArgsConstructor @JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) -@JsonPropertyOrder({"id", "userId", "planName", "theme", "startDate", "endDate", "planBudget", "scope", "numberOfMembers", "numberOfLikes", "numberOfScraps", "waypoints", "responseScheduleListDtos"}) -public class ListPlanDto { +@JsonPropertyOrder({"id", "userId", "planName", "theme", "startDate", "endDate" + , "planBudget", "scope", "numberOfMembers", "numberOfScraps", "regionList", "schedules"}) +public class SelectListPlanDto { @JsonProperty("plan_id") private Long id; @@ -38,19 +39,17 @@ public class ListPlanDto { private int numberOfMembers; - private int numberOfLikes; - private int numberOfScraps; @Setter - private List waypoints; + private List regionList; // 방문 장소 경로 ex) [ 서울 -> 부산 -> 서울 ] - @JsonProperty("schedule") + @JsonProperty("schedules") @Setter - private List listPlanScheduleDtoList; + private List listPlanScheduleDtoList; - public static ListPlanDto toDto(Plan plan) { - return ListPlanDto.builder() + public static SelectListPlanDto toDto(Plan plan) { + return SelectListPlanDto.builder() .id(plan.getId()) .userId(plan.getUserId()) .planName(plan.getPlanName()) @@ -59,7 +58,6 @@ public static ListPlanDto toDto(Plan plan) { .endDate(plan.getEndDate()) .scope(plan.isScope()) .numberOfMembers(plan.getNumberOfMembers()) - .numberOfLikes(plan.getNumberOfLikes()) .numberOfScraps(plan.getNumberOfScraps()) .build(); } diff --git a/src/main/java/com/minizin/travel/plan/dto/ListPlanScheduleDto.java b/src/main/java/com/minizin/travel/plan/dto/SelectListPlanScheduleDto.java similarity index 79% rename from src/main/java/com/minizin/travel/plan/dto/ListPlanScheduleDto.java rename to src/main/java/com/minizin/travel/plan/dto/SelectListPlanScheduleDto.java index bbd8001..1719571 100644 --- a/src/main/java/com/minizin/travel/plan/dto/ListPlanScheduleDto.java +++ b/src/main/java/com/minizin/travel/plan/dto/SelectListPlanScheduleDto.java @@ -12,6 +12,7 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.time.format.DateTimeFormatter; // 2024.06.05 내 여행 일정 조회 // @Builder @@ -20,7 +21,7 @@ @AllArgsConstructor @JsonPropertyOrder({"id", "scheduleDate", "placeName", "arrivalTime", "x", "y"}) @JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) -public class ListPlanScheduleDto { +public class SelectListPlanScheduleDto { @JsonProperty("schedule_id") private Long id; @@ -29,7 +30,7 @@ public class ListPlanScheduleDto { private String placeName; - private LocalTime arrivalTime; + private String arrivalTime; private Double x; private Double y; @@ -38,12 +39,12 @@ public class ListPlanScheduleDto { private String placeCategory; - public static ListPlanScheduleDto toDto(PlanSchedule planSchedule) { - return ListPlanScheduleDto.builder() + public static SelectListPlanScheduleDto toDto(PlanSchedule planSchedule) { + return SelectListPlanScheduleDto.builder() .id(planSchedule.getId()) .scheduleDate(planSchedule.getScheduleDate()) .placeName(planSchedule.getPlaceName()) - .arrivalTime(planSchedule.getArrivalTime()) + .arrivalTime(planSchedule.getArrivalTime().format(DateTimeFormatter.ofPattern("HH:mm"))) .x(planSchedule.getX()) .y(planSchedule.getY()) .placeAddr(planSchedule.getPlaceAddr()) diff --git a/src/main/java/com/minizin/travel/plan/dto/UpcomingPlanDto.java b/src/main/java/com/minizin/travel/plan/dto/UpcomingPlanDto.java new file mode 100644 index 0000000..6bb273c --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/dto/UpcomingPlanDto.java @@ -0,0 +1,49 @@ +package com.minizin.travel.plan.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.Plan; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Builder +@Getter +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +@JsonPropertyOrder({"id", "userId", "planName", "theme", "startDate", "endDate", "planBudget", "numberOfMembers"}) +public class UpcomingPlanDto { + + @JsonProperty("plan_id") + private Long id; + + private Long userId; + + private String planName; + + private String theme; + + private LocalDate startDate; + + private LocalDate endDate; + + @Setter + private int planBudget; + + private int numberOfMembers; + + public static UpcomingPlanDto toDto(Plan plan) { + return UpcomingPlanDto.builder() + .id(plan.getId()) + .userId(plan.getUserId()) + .planName(plan.getPlanName()) + .theme(plan.getTheme()) + .startDate(plan.getStartDate()) + .endDate(plan.getEndDate()) + .numberOfMembers(plan.getNumberOfMembers()) + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/plan/entity/Plan.java b/src/main/java/com/minizin/travel/plan/entity/Plan.java index 5bf74a8..8cbd273 100644 --- a/src/main/java/com/minizin/travel/plan/entity/Plan.java +++ b/src/main/java/com/minizin/travel/plan/entity/Plan.java @@ -40,9 +40,7 @@ public class Plan { @Column(name = "number_of_members") private int numberOfMembers; - @Column(name = "number_of_likes") - private int numberOfLikes; - + @Setter @Column(name = "number_of_scraps") private int numberOfScraps; diff --git a/src/main/java/com/minizin/travel/plan/entity/PlanBudget.java b/src/main/java/com/minizin/travel/plan/entity/PlanBudget.java index 54fdd8c..28ef59c 100644 --- a/src/main/java/com/minizin/travel/plan/entity/PlanBudget.java +++ b/src/main/java/com/minizin/travel/plan/entity/PlanBudget.java @@ -27,9 +27,6 @@ public class PlanBudget { private int cost; - @Column(name = "budget_memo") - private String budgetMemo; - @CreatedDate @Column(name = "created_at") private LocalDateTime createdAt; diff --git a/src/main/java/com/minizin/travel/plan/repository/PlanBudgetRepository.java b/src/main/java/com/minizin/travel/plan/repository/PlanBudgetRepository.java index 6d2ea9f..41b885c 100644 --- a/src/main/java/com/minizin/travel/plan/repository/PlanBudgetRepository.java +++ b/src/main/java/com/minizin/travel/plan/repository/PlanBudgetRepository.java @@ -10,4 +10,7 @@ public interface PlanBudgetRepository extends JpaRepository { List findAllByScheduleId(Long scheduleId); + + // #47 2024.06.13 내 여행 일정 삭제 + void deleteByScheduleId(Long scheduleId); } diff --git a/src/main/java/com/minizin/travel/plan/repository/PlanRepository.java b/src/main/java/com/minizin/travel/plan/repository/PlanRepository.java index 5646bc0..ea087a1 100644 --- a/src/main/java/com/minizin/travel/plan/repository/PlanRepository.java +++ b/src/main/java/com/minizin/travel/plan/repository/PlanRepository.java @@ -1,10 +1,15 @@ package com.minizin.travel.plan.repository; import com.minizin.travel.plan.entity.Plan; +import com.minizin.travel.plan.entity.PlanSchedule; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; @Repository @@ -14,4 +19,64 @@ public interface PlanRepository extends JpaRepository { List findAllByUserIdOrderByIdDesc(Long userId, Pageable pageable); + boolean existsByIdLessThanAndUserId(Long id, Long userId); + + boolean existsByIdAndUserId(Long id, Long userId); + + // #39 2024.06.10 다가오는 여행 일정 조회 // + List findTop6ByUserIdAndStartDateGreaterThanEqualOrderByStartDateAscIdDesc(Long userId, LocalDate today); + + // #48 2024.06.10 다른 사람 여행 일정 조회 START // + @Query("select DISTINCT p from Plan p, PlanSchedule s\n" + + "where p.id = s.planId and p.id < :lastPlanId\n" + + "and (:region IS NULL OR s.placeAddr like %:region%)\n" + + "and (:theme IS NULL OR p.theme = :theme )\n" + + "and (:search IS NULL OR (p.planName like %:search%\n" + + "OR s.placeAddr like %:search%\n" + + "OR s.placeName like %:search%))\n" + + "and p.scope is true order by p.id desc") + List findLessThanSearchAndThemeAndRegionOrderByIdDesc( + @Param("lastPlanId") Long lastPlanId, @Param("region") String region, @Param("theme") String theme, @Param("search") String search, Pageable pageable); + + @Query("select DISTINCT p from Plan p, PlanSchedule s\n" + + "where p.id = s.planId\n" + + "and (:region IS NULL OR s.placeAddr like %:region%)\n" + + "and (:theme IS NULL OR p.theme = :theme )\n" + + "and (:search IS NULL OR (p.planName like %:search%\n" + + "OR s.placeAddr like %:search%\n" + + "OR s.placeName like %:search%))\n" + + "and p.scope is true order by p.id desc") + List findSearchAndThemeAndRegionOrderByIdDesc( + @Param("region") String region, @Param("theme") String theme, @Param("search") String search, Pageable pageable); + // #48 2024.06.10 다른 사람 여행 일정 조회 END // + + // #58 2024.06.12 다른 사람 여행 일정 조회(북마크순) START // + @Query("select DISTINCT p from Plan p, PlanSchedule s\n" + + "where p.id = s.planId and p.id < :lastPlanId\n" + + "and (:region IS NULL OR s.placeAddr like %:region%)\n" + + "and (:theme IS NULL OR p.theme = :theme )\n" + + "and (:search IS NULL OR (p.planName like %:search%\n" + + "OR s.placeAddr like %:search%\n" + + "OR s.placeName like %:search%))\n" + + "and p.scope is true order by p.numberOfScraps desc, p.id desc") + List findLessThanSearchAndThemeAndRegionOrderByNumberOfScrapsDescIdDesc( + @Param("lastPlanId") Long lastPlanId, @Param("region") String region, @Param("theme") String theme, @Param("search") String search, Pageable pageable); + + @Query("select DISTINCT p from Plan p, PlanSchedule s\n" + + "where p.id = s.planId\n" + + "and (:region IS NULL OR s.placeAddr like %:region%)\n" + + "and (:theme IS NULL OR p.theme = :theme )\n" + + "and (:search IS NULL OR (p.planName like %:search%\n" + + "OR s.placeAddr like %:search%\n" + + "OR s.placeName like %:search%))\n" + + "and p.scope is true order by p.numberOfScraps desc, p.id desc") + List findSearchAndThemeAndRegionOrderByNumberOfScrapsDescIdDesc( + @Param("region") String region, @Param("theme") String theme, @Param("search") String search, Pageable pageable); + // #58 2024.06.12 다른 사람 여행 일정 조회(북마크순) END // + + // #129 금주 인기 여행 + //List findTop20ByStartDateBetweenOrderByNumberOfScrapsDescIdDesc(LocalDate startDate, LocalDate endDate); + + List findTop20ByCreatedAtBetweenOrderByNumberOfScrapsDescIdDesc(LocalDateTime startDate, LocalDateTime endDate); + } diff --git a/src/main/java/com/minizin/travel/plan/repository/PlanScheduleRepository.java b/src/main/java/com/minizin/travel/plan/repository/PlanScheduleRepository.java index 5aa6249..3f4c6c0 100644 --- a/src/main/java/com/minizin/travel/plan/repository/PlanScheduleRepository.java +++ b/src/main/java/com/minizin/travel/plan/repository/PlanScheduleRepository.java @@ -10,4 +10,7 @@ public interface PlanScheduleRepository extends JpaRepository { List findAllByPlanId(Long planId); + + // #47 2024.06.13 내 여행 일정 삭제 + void deleteByPlanId(Long planId); } diff --git a/src/main/java/com/minizin/travel/plan/service/OtherPlanService.java b/src/main/java/com/minizin/travel/plan/service/OtherPlanService.java new file mode 100644 index 0000000..13101bb --- /dev/null +++ b/src/main/java/com/minizin/travel/plan/service/OtherPlanService.java @@ -0,0 +1,195 @@ +package com.minizin.travel.plan.service; + +import com.minizin.travel.plan.dto.*; +import com.minizin.travel.plan.entity.Plan; +import com.minizin.travel.plan.entity.PlanSchedule; +import com.minizin.travel.plan.repository.PlanRepository; +import com.minizin.travel.plan.repository.PlanScheduleRepository; +import com.minizin.travel.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.thymeleaf.util.StringUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class OtherPlanService { + + private final PlanService planService; + private final PlanRepository planRepository; + private final PlanScheduleRepository planScheduleRepository; + + final int DEFAULT_PAGE_SIZE = 6; // #29 + + private final UserRepository userRepository; + + // #48 2024.06.10 다른 사람 여행 일정 조회 START // + public ResponseOthersPlanDto selectOthersListPlan( + Long lastPlanId, String region, String theme, String search) { + + Pageable page = PageRequest.of(0, DEFAULT_PAGE_SIZE); + + List planList = findOtherPlanByLastPlanIdCheckExistCursor(region, theme, search, lastPlanId, page); + List othersListPlanDtoList = new ArrayList<>(); + Long planId = 0L; + + for (Plan plan : planList) { + planId = plan.getId(); + othersListPlanDtoList.add(selectOthersPlanAndSchedule(plan)); + } + + if (findOtherPlanByLastPlanIdCheckExistCursor(region, theme, search, planId, page).isEmpty()) { + + planId = null; + } + + return ResponseOthersPlanDto.builder() + .data(othersListPlanDtoList) + .nextCursor(planId) + .build(); + } + + // #58 2024.06.12 다른 사람 여행 일정 조회(북마크순) START // + public ResponseOthersPlanDto selectOthersListPlanScraps( + Long lastPlanId, String region, String theme, String search) { + + Pageable page = PageRequest.of(0, DEFAULT_PAGE_SIZE); + + List planList = findOtherPlanScrapsByLastPlanIdCheckExistCursor(region, theme, search, lastPlanId, page); + List othersListPlanDtoList = new ArrayList<>(); + Long planId = 0L; + + for (Plan plan : planList) { + planId = plan.getId(); + othersListPlanDtoList.add(selectOthersPlanAndSchedule(plan)); + } + + if (findOtherPlanScrapsByLastPlanIdCheckExistCursor(region, theme, search, planId, page).isEmpty()) { + planId = null; + } + + return ResponseOthersPlanDto.builder() + .data(othersListPlanDtoList) + .nextCursor(planId) + .build(); + } + // #58 2024.06.12 다른 사람 여행 일정 조회(북마크순) END // + +/* + private List selectOthersListPlan(List planList) { + + List othersListPlanDtoList = new ArrayList<>(); + Long planId = 0L; + + for (Plan plan : planList) { + planId = plan.getId(); + othersListPlanDtoList.add(selectOthersPlanAndSchedule(plan)); + } + + return othersListPlanDtoList; + } + */ + + private OthersListPlanDto selectOthersPlanAndSchedule(Plan plan) { + + List planScheduleList = planScheduleRepository.findAllByPlanId(plan.getId()); + List othersListPlanScheduleDtoList = new ArrayList<>(); + List regionList = new ArrayList<>(); + + for (PlanSchedule planSchedule : planScheduleList) { + othersListPlanScheduleDtoList.add(OthersListPlanScheduleDto.toDto(planSchedule)); + regionList.add(planSchedule.getRegion()); + } + + OthersListPlanDto othersListPlanDto = OthersListPlanDto.toDto(plan); + othersListPlanDto.setUserNickname(userRepository.findById(plan.getUserId()).get().getNickname()); + othersListPlanDto.setRegionList(planService.duplicateRegionList(regionList)); + othersListPlanDto.setPlanBudget(planService.calculateTotalPlanBudget(planScheduleList)); + othersListPlanDto.setOthersListPlanScheduleDtoList(othersListPlanScheduleDtoList); + + return othersListPlanDto; + } + + private List findOtherPlanByLastPlanIdCheckExistCursor(String region, String theme, String search, Long lastPlanId, Pageable page) { + + if (StringUtils.isEmpty(region)) { + region = null; + } + if (StringUtils.isEmpty(theme)) { + theme = null; + } + if (StringUtils.isEmpty(search)) { + search = null; + } + + return lastPlanId == 0 ? planRepository.findSearchAndThemeAndRegionOrderByIdDesc(region, theme, search, page) + : planRepository.findLessThanSearchAndThemeAndRegionOrderByIdDesc(lastPlanId, region, theme, search, page); + } + // #48 2024.06.10 다른 사람 여행 일정 조회 END // + + // #58 2024.06.12 다른 사람 여행 일정 조회(북마크순) START // + private List findOtherPlanScrapsByLastPlanIdCheckExistCursor(String region, String theme, String search, Long lastPlanId, Pageable page) { + + if (StringUtils.isEmpty(region)) { + region = null; + } + if (StringUtils.isEmpty(theme)) { + theme = null; + } + if (StringUtils.isEmpty(search)) { + search = null; + } + return lastPlanId == 0? planRepository.findSearchAndThemeAndRegionOrderByNumberOfScrapsDescIdDesc(region, theme, search, page) + : planRepository.findLessThanSearchAndThemeAndRegionOrderByNumberOfScrapsDescIdDesc(lastPlanId, region, theme, search, page); + } + // #58 2024.06.12 다른 사람 여행 일정 조회(북마크순) END // + + // #106 2024.06.16 금주 인기 여행 일정 조회(북마크순) START // + public List selectPopularPlansWeek() { + + List popWeekPlanDtoList = new ArrayList<>(); + + LocalDateTime endDate = LocalDateTime.now(); + LocalDateTime startDate = endDate.minusDays(6); + List planList = planRepository.findTop20ByCreatedAtBetweenOrderByNumberOfScrapsDescIdDesc(startDate, endDate); + + for (Plan plan : planList) { + PopWeekPlanDto newPopWeekPlanDto = PopWeekPlanDto.toDto(plan); + List planScheduleList = planScheduleRepository.findAllByPlanId(plan.getId()); + newPopWeekPlanDto.setPlanBudget(planService.calculateTotalPlanBudget(planScheduleList)); + newPopWeekPlanDto.setUserNickname(userRepository.findById(plan.getUserId()).get().getNickname()); + popWeekPlanDtoList.add(newPopWeekPlanDto); + } + + return popWeekPlanDtoList; + } + // #106 2024.06.16 금주 인기 여행 일정 조회(북마크순) END // + + // #129 다른 사람의 여행 일정 상세 보기 START // + public DetailPlanDto selectOtherDetailPlan(Long planId) { + + if (!planRepository.existsById(planId)) { + return DetailPlanDto.existsNot(planId); + } + + Plan plan = planRepository.findById(planId).get(); + + if (!plan.isScope()) { + return DetailPlanDto.notAuth(planId, "비공개 plan 입니다."); + } + + DetailPlanDto detailPlanDto = planService.findListOfPlanScheduleDtoAndPlanBudgetDto(plan); + detailPlanDto.setUserNickname(userRepository.findById(plan.getUserId()).get().getNickname()); + + return detailPlanDto; + } + // #129 다른 사람의 여행 일정 상세 보기 END // +} diff --git a/src/main/java/com/minizin/travel/plan/service/PlanService.java b/src/main/java/com/minizin/travel/plan/service/PlanService.java index b9be39f..b012fde 100644 --- a/src/main/java/com/minizin/travel/plan/service/PlanService.java +++ b/src/main/java/com/minizin/travel/plan/service/PlanService.java @@ -7,16 +7,21 @@ import com.minizin.travel.plan.repository.PlanBudgetRepository; import com.minizin.travel.plan.repository.PlanRepository; import com.minizin.travel.plan.repository.PlanScheduleRepository; +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import com.minizin.travel.user.domain.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.coyote.BadRequestException; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -28,22 +33,82 @@ public class PlanService { private final PlanRepository planRepository; private final PlanScheduleRepository planScheduleRepository; private final PlanBudgetRepository planBudgetRepository; + private final UserRepository userRepository; final int INITIAL_VALUE = 0; final int DEFAULT_PAGE_SIZE = 6; // #29 // #28 2024.05.30 내 여행 일정 생성하기 START // - public ResponsePlanDto createPlan(PlanDto planDto) { + public ResponsePlanDto createPlan(PlanDto planDto, PrincipalDetails user) + throws BadRequestException { + + // user 정보 확인 + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); + + // #87 Request 예외/에러 처리 START + // 여행 일자 최소 1일 ~ 최대 60일 START // + LocalDate startDate = LocalDate.parse(planDto.getStartDate()); + LocalDate endDate = LocalDate.parse(planDto.getEndDate()); + int planDays = (int) ChronoUnit.DAYS.between(startDate, endDate); + if (planDays < 0 || planDays > 60) { + System.out.println("여행의 날짜는 최소 1일 ~ 최대 60일이어야 합니다."); + throw new BadRequestException(); + } + + // 일정당 장소 최소 1개 + String curDate = ""; + int scheduleCnt = 0; + for (PlanScheduleDto planScheduleDto : planDto.getPlanScheduleDtos()) { + if (curDate.equals(planScheduleDto.getScheduleDate())) { + scheduleCnt++; + if (scheduleCnt > 40) { + System.out.println("일정의 개수는 하루당 40개를 넘을 수 없습니다."); + throw new BadRequestException(); + } + } else { + curDate = planScheduleDto.getScheduleDate(); + scheduleCnt = 1; + } + } + + // 예산 ~ 100,000,000원 + int totalPlanBudget = 0; + for (PlanScheduleDto planScheduleDto : planDto.getPlanScheduleDtos()) { + + if (planScheduleDto.getPlanBudgetDtos() != null) { // #87 Request 예외/에러 처리 + for (PlanBudgetDto planBudgetDto : planScheduleDto.getPlanBudgetDtos()) { + totalPlanBudget += planBudgetDto.getCost(); + } + } + } + if (totalPlanBudget > 100000000) { + throw new BadRequestException(); + } + + // schedule 날짜가 유효하지 않은 경우 + for (PlanScheduleDto planScheduleDto : planDto.getPlanScheduleDtos()) { + LocalDate scheduleDate = LocalDate.parse(planScheduleDto.getScheduleDate()); + if (scheduleDate.isBefore(startDate) + || scheduleDate.isAfter(endDate)) { + + return ResponsePlanDto.fail( + ResponsePlanDto.Data.builder() + .startDate(planDto.getStartDate()) + .endDate(planDto.getEndDate()) + .scheduleDate(planScheduleDto.getScheduleDate()) + .build()); + } + } + // #87 Request 예외/에러 처리 END // Plan newPlan = planRepository.save(Plan.builder() - .userId(planDto.getUserId()) + .userId(userId) .planName(planDto.getPlanName()) .theme(planDto.getTheme()) - .startDate(planDto.getStartDate()) - .endDate(planDto.getEndDate()) + .startDate(startDate) + .endDate(endDate) .scope(planDto.isScope()) .numberOfMembers(planDto.getNumberOfMembers()) - .numberOfLikes(INITIAL_VALUE) .numberOfScraps(INITIAL_VALUE) .createdAt(LocalDateTime.now()) .modifiedAt(LocalDateTime.now()) @@ -52,37 +117,30 @@ public ResponsePlanDto createPlan(PlanDto planDto) { Long planId = newPlan.getId(); for (PlanScheduleDto planScheduleDto : planDto.getPlanScheduleDtos()) { - Long scheduleId = createPlanSchedule(planScheduleDto, planId).getId(); + Long scheduleId = createPlanSchedule(planScheduleDto, planId).getId(); - for (PlanBudgetDto planBudgetDto : planScheduleDto.getPlanBudgetDtos()) { - createPlanBudget(planBudgetDto, scheduleId); + if (planScheduleDto.getPlanBudgetDtos() != null) { // #87 Request 예외/에러 처리 + for (PlanBudgetDto planBudgetDto : planScheduleDto.getPlanBudgetDtos()) { + createPlanBudget(planBudgetDto, scheduleId); + } } } - ResponsePlanDto newResponsePlanDto = ResponsePlanDto.builder() - .success(true) - .message("일정을 생성하였습니다.") - .planId(planId) - .numberOfLikes(newPlan.getNumberOfLikes()) - .numberOfScraps(newPlan.getNumberOfScraps()) - .createAt(newPlan.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) - .updatedAt(newPlan.getModifiedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) - .build(); + return ResponsePlanDto.success(newPlan); // #87 Request 예외/에러 처리 : Response success 코드 정리 - return newResponsePlanDto; } public PlanSchedule createPlanSchedule(PlanScheduleDto planScheduleDto, Long planId) { return planScheduleRepository.save(PlanSchedule.builder() .planId(planId) - .scheduleDate(planScheduleDto.getScheduleDate()) + .scheduleDate(LocalDate.parse(planScheduleDto.getScheduleDate())) .placeCategory(planScheduleDto.getPlaceCategory()) .placeName(planScheduleDto.getPlaceName()) .placeAddr(planScheduleDto.getPlaceAddr()) // #29 2024.06.02 내 여행 일정 조회 .region(planScheduleDto.getRegion()) .placeMemo(planScheduleDto.getPlaceMemo()) - .arrivalTime(LocalTime.parse(planScheduleDto.getArrivalTime(), DateTimeFormatter.ofPattern("HH:mm:ss"))) + .arrivalTime(LocalTime.parse(planScheduleDto.getArrivalTime(), DateTimeFormatter.ofPattern("HH:mm"))) .x(planScheduleDto.getX()) .y(planScheduleDto.getY()) .createdAt(LocalDateTime.now()) @@ -96,7 +154,6 @@ public PlanBudget createPlanBudget(PlanBudgetDto planBudgetDto, Long scheduleId) .scheduleId(scheduleId) .budgetCategory(planBudgetDto.getBudgetCategory()) .cost(planBudgetDto.getCost()) - .budgetMemo(planBudgetDto.getBudgetMemo()) .createdAt(LocalDateTime.now()) .modifiedAt(LocalDateTime.now()) .build()); @@ -104,71 +161,75 @@ public PlanBudget createPlanBudget(PlanBudgetDto planBudgetDto, Long scheduleId) // #28 2024.05.30 내 여행 일정 생성하기 END // // #29 2024.06.02 내 여행 일정 조회 START // - public ResponseListPlanDto selectListPlan(Long cursorId) { + public ResponseSelectListPlanDto selectListPlan(Long lastPlanId, PrincipalDetails user) { Pageable page = PageRequest.of(0, DEFAULT_PAGE_SIZE); - // 테스트 - Long userId = 1L; + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); - List planList = findAllByCursorIdCheckExistCursor(userId, cursorId, page); - List listPlanDtoList = new ArrayList<>(); + List planList = findAllByLastPlanIdCheckExistCursor(userId, lastPlanId, page); + List listPlanDtoList = new ArrayList<>(); Long planId = 0L; for (Plan plan : planList) { planId = plan.getId(); List planScheduleList = planScheduleRepository.findAllByPlanId(planId); - List listPlanScheduleDtoList = new ArrayList<>(); - List waypoints = new ArrayList<>(); + List listPlanScheduleDtoList = new ArrayList<>(); + List regionList = new ArrayList<>(); int totalBudget = calculateTotalPlanBudget(planScheduleList); // #44 여행 일정 예산 추가 for (PlanSchedule planSchedule : planScheduleList) { - ListPlanScheduleDto newResponseScheduleDto = ListPlanScheduleDto.toDto(planSchedule); - listPlanScheduleDtoList.add(newResponseScheduleDto); - waypoints.add(planSchedule.getRegion()); + listPlanScheduleDtoList.add(SelectListPlanScheduleDto.toDto(planSchedule)); + regionList.add(planSchedule.getRegion()); } - ListPlanDto newPlanDto = ListPlanDto.toDto(plan); - newPlanDto.setPlanBudget(totalBudget); // #44 여행 일정 예산 추가 - newPlanDto.setListPlanScheduleDtoList(listPlanScheduleDtoList); - newPlanDto.setWaypoints(duplicateWaypoints(waypoints)); - listPlanDtoList.add(newPlanDto); + + SelectListPlanDto selectListPlanDto = SelectListPlanDto.toDto(plan); + selectListPlanDto.setPlanBudget(totalBudget); // #44 여행 일정 예산 추가 + selectListPlanDto.setListPlanScheduleDtoList(listPlanScheduleDtoList); + selectListPlanDto.setRegionList(duplicateRegionList(regionList)); + listPlanDtoList.add(selectListPlanDto); } - return ResponseListPlanDto.builder() + if (!planRepository.existsByIdLessThanAndUserId(planId, userId)) { + planId = null; + } + + return ResponseSelectListPlanDto.builder() .data(listPlanDtoList) .nextCursor(planId) .build(); } - private List duplicateWaypoints(List waypoints) { + public List duplicateRegionList(List regionList) { - if (waypoints.size() <= 1) { - return waypoints; + if (regionList.size() <= 1) { + return regionList; } - List newWaypoints = new ArrayList<>(); - newWaypoints.add(waypoints.get(0)); + List newRegionList = new ArrayList<>(); + newRegionList.add(regionList.get(0)); - for (int i = 1; i < waypoints.size(); i++) { - if (waypoints.get(i - 1).equals(waypoints.get(i))) { + // [ 서울 -> 서울 -> 부산 ] 인 경우 [ 서울 -> 부산 ] 으로 중복 제거 + for (int i = 1; i < regionList.size(); i++) { + if (regionList.get(i - 1).equals(regionList.get(i))) { continue; } - newWaypoints.add(waypoints.get(i)); + newRegionList.add(regionList.get(i)); } - return newWaypoints; + return newRegionList; } - private List findAllByCursorIdCheckExistCursor(Long userId, Long cursorId, Pageable page) { + private List findAllByLastPlanIdCheckExistCursor(Long userId, Long lastPlanId, Pageable page) { - return cursorId == 0 ? planRepository.findAllByUserIdOrderByIdDesc(userId, page) - : planRepository.findByIdLessThanAndUserIdOrderByIdDesc(cursorId, userId, page); + return lastPlanId == 0 ? planRepository.findAllByUserIdOrderByIdDesc(userId, page) + : planRepository.findByIdLessThanAndUserIdOrderByIdDesc(lastPlanId, userId, page); } // #29 2024.06.02 내 여행 일정 조회 END // // #44 2024.06.12 여행 일정 예산 계산하기 START // - private int calculateTotalPlanBudget(List planScheduleList) { + public int calculateTotalPlanBudget(List planScheduleList) { int totalPlanBudget = 0; @@ -183,4 +244,224 @@ private int calculateTotalPlanBudget(List planScheduleList) { return totalPlanBudget; } // #44 2024.06.12 여행 일정 예산 계산하기 END // + + // #32 2024.06.07 내 여행 일정 수정 START // + @Transactional + public ResponseEditPlanDto updatePlan(Long planId, EditPlanDto editPlanDto, PrincipalDetails user) { + + if (!planRepository.existsById(planId)) { + return ResponseEditPlanDto.builder() + .success(false) + .message("요청하신 plan 은 존재하지 않습니다.") + .planId(planId) + .build(); + } + + /* 추후 PUT요청에 id값이 포함되면 변경 예정 */ + + // 기존 PlanSchedule 및 PlanBudget 삭제 + List planScheduleList = planScheduleRepository.findAllByPlanId(planId); + for (PlanSchedule planSchedule : planScheduleList) { + planBudgetRepository.deleteByScheduleId(planSchedule.getId()); + } + planScheduleRepository.deleteByPlanId(planId); + + // plan id 로 plan DB 찾아오기 + // plan DB 에 저장 + Plan plan = planRepository.findById(planId).get(); + + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); + if (!plan.getUserId().equals(userId)) { + return ResponseEditPlanDto.builder() + .success(false) + .message("로그인한 사용자의 id가 아닙니다.") + .planId(planId) + .build(); + } + + Plan newPlan = planRepository.save(Plan.builder() + .id(planId) + .userId(plan.getUserId()) + .planName(editPlanDto.getPlanName()) + .theme(editPlanDto.getTheme()) + .startDate(LocalDate.parse(editPlanDto.getStartDate())) + .endDate(LocalDate.parse(editPlanDto.getEndDate())) + .scope(editPlanDto.isScope()) + .numberOfMembers(editPlanDto.getNumberOfMembers()) + .numberOfScraps(plan.getNumberOfScraps()) + .createdAt(plan.getCreatedAt()) + .modifiedAt(LocalDateTime.now()) + .build()); + + for (PlanScheduleDto planScheduleDto : editPlanDto.getPlanScheduleDtos()) { + Long scheduleId = createPlanSchedule(planScheduleDto, planId).getId(); + + for (PlanBudgetDto planBudgetDto : planScheduleDto.getPlanBudgetDtos()) { + createPlanBudget(planBudgetDto, scheduleId); + } + } + + return ResponseEditPlanDto.builder() + .success(true) + .message("일정을 수정하였습니다.") + .planId(planId) + .createdAt(newPlan.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .updatedAt(newPlan.getModifiedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .numberOfScraps(newPlan.getNumberOfScraps()) + .build(); + } + // #32 2024.06.07 내 여행 일정 수정 END // + + // #38 2024.06.08 내 여행 일정 상세 보기 START // + public DetailPlanDto selectDetailPlan(Long planId, PrincipalDetails user) { + + if (!planRepository.existsById(planId)) { + return DetailPlanDto.existsNot(planId); + } + + Plan plan = planRepository.findById(planId).get(); + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); + if (!plan.getUserId().equals(userId)) { + return DetailPlanDto.notAuth(planId, "로그인한 사용자의 plan 이 아닙니다."); + } + + return findListOfPlanScheduleDtoAndPlanBudgetDto(plan); + } + + public DetailPlanDto findListOfPlanScheduleDtoAndPlanBudgetDto(Plan plan) { + + List planScheduleList = planScheduleRepository.findAllByPlanId(plan.getId()); // DB를 가져온 list + List detailPlanScheduleDtoList = new ArrayList<>(); // 객체를 가져온 list + List regionList = new ArrayList<>(); + int days = 0; + + for (PlanSchedule planSchedule : planScheduleList) { + List planBudgetList = planBudgetRepository.findAllByScheduleId(planSchedule.getId()); + List detailPlanBudgetDtoList = new ArrayList<>(); + + for (PlanBudget planBudget : planBudgetList) { + detailPlanBudgetDtoList.add(DetailPlanBudgetDto.toDto(planBudget)); + } + + DetailPlanScheduleDto detailPlanScheduleDto = DetailPlanScheduleDto.toDto(planSchedule); + regionList.add(planSchedule.getRegion()); + detailPlanScheduleDto.setScheduleDays((int) ChronoUnit.DAYS.between(plan.getStartDate(), detailPlanScheduleDto.getScheduleDate()) + 1); + detailPlanScheduleDto.setDetailPlanBudgetDtoList(detailPlanBudgetDtoList); + detailPlanScheduleDtoList.add(detailPlanScheduleDto); + } + + DetailPlanDto detailPlanDto = DetailPlanDto.toDto(plan); + detailPlanDto.setPlanBudget(calculateTotalPlanBudget(planScheduleList)); + detailPlanDto.setRegionList(duplicateRegionList(regionList)); + detailPlanDto.setDetailPlanScheduleDtoList(detailPlanScheduleDtoList); + + return detailPlanDto; + } + // #38 2024.06.08 내 여행 일정 상세 보기 END // + + // #47 2024.06.13 내 여행 일정 삭제 START // + @Transactional + public ResponseDeletePlanDto deletePlan(Long planId, PrincipalDetails user) { + + if (!planRepository.existsById(planId)) { + return ResponseDeletePlanDto.builder() + .success(false) + .message("여행 일정 id가 유효하지 않습니다.") + .planId(planId) + .build(); + } + + Plan plan = planRepository.findById(planId).get(); + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); + if (!plan.getUserId().equals(userId)) { + return ResponseDeletePlanDto.builder() + .success(false) + .message("로그인한 사용자의 plan 이 아닙니다.") + .planId(planId) + .build(); + } + + List planScheduleList = planScheduleRepository.findAllByPlanId(planId); + for (PlanSchedule planSchedule : planScheduleList) { + planBudgetRepository.deleteByScheduleId(planSchedule.getId()); + } + planScheduleRepository.deleteByPlanId(planId); + + planRepository.deleteById(planId); + + return ResponseDeletePlanDto.builder() + .success(true) + .message("일정을 삭제하였습니다.") + .planId(planId) + .build(); + } + // #47 2024.06.13 내 여행 일정 삭제 END // + + // #39 2024.06.10 다가오는 여행 일정 조회 START // + public List selectUpcomingPlan(PrincipalDetails user) { + + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); + + List upcomingPlanDtoList = new ArrayList<>(); + List planList = planRepository.findTop6ByUserIdAndStartDateGreaterThanEqualOrderByStartDateAscIdDesc(userId, LocalDate.now()); + + for (Plan plan : planList) { + List planScheduleList = planScheduleRepository.findAllByPlanId(plan.getId()); + + UpcomingPlanDto upcomingPlanDto = UpcomingPlanDto.toDto(plan); + upcomingPlanDto.setPlanBudget(calculateTotalPlanBudget(planScheduleList)); + upcomingPlanDtoList.add(upcomingPlanDto); + } + + return upcomingPlanDtoList; + } + // #39 2024.06.10 다가오는 여행 일정 조회 END // + + // #107 2024.06.20 일정 복사하기 START // + public ResponsePlanDto copyAndCreatePlan(Long planId, PrincipalDetails user) { + + if (!planRepository.existsById(planId)) { + return ResponsePlanDto.existsNot(planId); + } + + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); + + Plan plan = planRepository.findById(planId).get(); + + int planDays = (int) ChronoUnit.DAYS.between(plan.getStartDate(), plan.getEndDate()); + LocalDate endDate = LocalDate.now().plusDays(planDays); + Plan newPlan = planRepository.save(Plan.builder() + .userId(userId) + .planName(plan.getPlanName()) + .theme(plan.getTheme()) + .startDate(LocalDate.now()) + .endDate(endDate) + .scope(plan.isScope()) + .numberOfMembers(plan.getNumberOfMembers()) + .numberOfScraps(INITIAL_VALUE) + .createdAt(LocalDateTime.now()) + .modifiedAt(LocalDateTime.now()) + .build()); + + // 기존 스케줄 찾기 + List planScheduleList = planScheduleRepository.findAllByPlanId(planId); + planDays = (int) ChronoUnit.DAYS.between(plan.getStartDate(), LocalDate.now()); + + for (PlanSchedule planSchedule : planScheduleList) { + PlanScheduleDto planScheduleDto = PlanScheduleDto.toDto(planSchedule); + planScheduleDto.setScheduleDate(String.valueOf(LocalDate.parse(planScheduleDto.getScheduleDate()).plusDays(planDays))); + Long scheduleId = createPlanSchedule(planScheduleDto, newPlan.getId()).getId(); + + // 기존 예산 찾기 + List planBudgetList = planBudgetRepository.findAllByScheduleId(planSchedule.getId()); + // 새 예산 넣기 + for (PlanBudget planBudget : planBudgetList) { + createPlanBudget(PlanBudgetDto.toDto(planBudget), scheduleId); + } + } + + return ResponsePlanDto.copySuccess(newPlan); // #87 Request 예외/에러 처리 : Response success 코드 정리 + + } + // #107 2024.06.20 일정 복사하기 END // } diff --git a/src/main/java/com/minizin/travel/scrap/controller/ScrapController.java b/src/main/java/com/minizin/travel/scrap/controller/ScrapController.java new file mode 100644 index 0000000..bf666d5 --- /dev/null +++ b/src/main/java/com/minizin/travel/scrap/controller/ScrapController.java @@ -0,0 +1,57 @@ +package com.minizin.travel.scrap.controller; + +import com.minizin.travel.scrap.service.ScrapService; +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@RestController +@RequiredArgsConstructor +public class ScrapController { + + private final ScrapService scrapService; + + // #49 스크랩 생성 START // + @PostMapping("/scraps/{plan_id}") + public ResponseEntity createScrap(@PathVariable("plan_id") Long planId, + @AuthenticationPrincipal PrincipalDetails user) { + + var result = scrapService.createScrap(planId, user); + + return ResponseEntity.ok(result); + } + // #49 스크랩 생성 END // + + // #50 스크랩 조회 START // + @GetMapping("/scraps") + public ResponseEntity selectListScrapedPlans(@RequestParam("cursor_id") Long cursorId, + @AuthenticationPrincipal PrincipalDetails user) { + + var result = scrapService.selectListScrapedPlans(cursorId, user); + + return ResponseEntity.ok(result); + } + // #50 스크랩 조회 END // + + // #51 스크랩 삭제 START // + @DeleteMapping("/scraps/{plan_id}") + public ResponseEntity deleteScrapedPlan(@PathVariable("plan_id") Long planId, + @AuthenticationPrincipal PrincipalDetails user) { + + var result = scrapService.deleteScrapedPlan(planId, user); + + return ResponseEntity.ok(result); + } + // #51 스크랩 삭제 END // + + @GetMapping("/scraps/check/{plan_id}") + public ResponseEntity checkScrapedPlan(@PathVariable("plan_id") Long planId, + @AuthenticationPrincipal PrincipalDetails user) { + + var result = scrapService.checkScrapedPlan(planId, user); + + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/com/minizin/travel/scrap/dto/ResponseCheckScrapedPlanDto.java b/src/main/java/com/minizin/travel/scrap/dto/ResponseCheckScrapedPlanDto.java new file mode 100644 index 0000000..1931199 --- /dev/null +++ b/src/main/java/com/minizin/travel/scrap/dto/ResponseCheckScrapedPlanDto.java @@ -0,0 +1,19 @@ +package com.minizin.travel.scrap.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponseCheckScrapedPlanDto { + + private boolean success; + + private String message; + + @JsonPropertyOrder("scrap_id") + private Long scrapId; +} diff --git a/src/main/java/com/minizin/travel/scrap/dto/ResponseCreateScrapPlanDto.java b/src/main/java/com/minizin/travel/scrap/dto/ResponseCreateScrapPlanDto.java new file mode 100644 index 0000000..620f638 --- /dev/null +++ b/src/main/java/com/minizin/travel/scrap/dto/ResponseCreateScrapPlanDto.java @@ -0,0 +1,47 @@ +package com.minizin.travel.scrap.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.minizin.travel.scrap.entity.Scrap; +import lombok.Builder; +import lombok.Getter; + +import java.time.format.DateTimeFormatter; + +@Builder +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponseCreateScrapPlanDto { + + @JsonProperty("scrap_id") + private Long id; + + private Long userId; + + private Long planId; + + private String createdAt; + + private boolean success; + + private String message; + + public static ResponseCreateScrapPlanDto toDto(Scrap scrap) { + return ResponseCreateScrapPlanDto.builder() + .success(true) + .id(scrap.getId()) + .userId(scrap.getUserId()) + .planId(scrap.getPlanId()) + .createdAt(scrap.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build(); + } + + public static ResponseCreateScrapPlanDto fail(Long planId, String message) { + + return ResponseCreateScrapPlanDto.builder() + .success(false) + .message(message) + .id(planId) + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/scrap/dto/ResponseDeleteScrapedPlanDto.java b/src/main/java/com/minizin/travel/scrap/dto/ResponseDeleteScrapedPlanDto.java new file mode 100644 index 0000000..7ba4d76 --- /dev/null +++ b/src/main/java/com/minizin/travel/scrap/dto/ResponseDeleteScrapedPlanDto.java @@ -0,0 +1,19 @@ +package com.minizin.travel.scrap.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponseDeleteScrapedPlanDto { + + private boolean success; + + private String message; + + @JsonPropertyOrder("scrap_id") + private Long scrapId; +} diff --git a/src/main/java/com/minizin/travel/scrap/dto/ResponseSelectScrapedPlansDto.java b/src/main/java/com/minizin/travel/scrap/dto/ResponseSelectScrapedPlansDto.java new file mode 100644 index 0000000..9a59b88 --- /dev/null +++ b/src/main/java/com/minizin/travel/scrap/dto/ResponseSelectScrapedPlansDto.java @@ -0,0 +1,33 @@ +package com.minizin.travel.scrap.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Builder +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +public class ResponseSelectScrapedPlansDto { + + private List data; + + private Long cursorId; + + private String message; + + @JsonProperty("scrap_id") + private Long id; + + public static ResponseSelectScrapedPlansDto fail(String message) { + + return ResponseSelectScrapedPlansDto.builder() + .message(message) + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/scrap/dto/SelectScrapedPlansDto.java b/src/main/java/com/minizin/travel/scrap/dto/SelectScrapedPlansDto.java new file mode 100644 index 0000000..f33c535 --- /dev/null +++ b/src/main/java/com/minizin/travel/scrap/dto/SelectScrapedPlansDto.java @@ -0,0 +1,59 @@ +package com.minizin.travel.scrap.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.plan.entity.Plan; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Builder +@Getter +@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) +@JsonPropertyOrder({"id", "planId", "userNickname", "planName", "theme", "startDate", "endDate", + "planBudget", "scope", "numberOfMembers", "numberOfScraps"}) +public class SelectScrapedPlansDto { + + @Setter + @JsonProperty("scrap_id") + private Long id; + + private Long planId; + + @Setter + private String userNickname; + + private String planName; + + private String theme; + + private LocalDate startDate; + + private LocalDate endDate; + + @Setter + private int planBudget; + + private boolean scope; + + private int numberOfMembers; + + private int numberOfScraps; + + public static SelectScrapedPlansDto toDto(Plan plan) { + return SelectScrapedPlansDto.builder() + .planId(plan.getId()) + .planName(plan.getPlanName()) + .theme(plan.getTheme()) + .startDate(plan.getStartDate()) + .endDate(plan.getEndDate()) + .scope(plan.isScope()) + .numberOfMembers(plan.getNumberOfMembers()) + .numberOfScraps(plan.getNumberOfScraps()) + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/scrap/entity/Scrap.java b/src/main/java/com/minizin/travel/scrap/entity/Scrap.java new file mode 100644 index 0000000..1f57154 --- /dev/null +++ b/src/main/java/com/minizin/travel/scrap/entity/Scrap.java @@ -0,0 +1,30 @@ +package com.minizin.travel.scrap.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Scrap { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "scrap_id") + private Long id; + + private Long userId; + + private Long planId; + + @CreatedDate + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/minizin/travel/scrap/repository/ScrapRepository.java b/src/main/java/com/minizin/travel/scrap/repository/ScrapRepository.java new file mode 100644 index 0000000..1969227 --- /dev/null +++ b/src/main/java/com/minizin/travel/scrap/repository/ScrapRepository.java @@ -0,0 +1,25 @@ +package com.minizin.travel.scrap.repository; + +import com.minizin.travel.scrap.entity.Scrap; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ScrapRepository extends JpaRepository { + + // #49 스크랩 생성 : 이미 스크랩한 일정인지 확인 // + boolean existsByPlanIdAndUserId(Long planId, Long userId); + + // #50 2024.06.12 스크랩 조회 : 해당 회원이 스크랩한 일정이 있는지 확인 // + boolean existsByUserId(Long userId); + + // #50 2024.06.12 스크랩 조회 : 해당 회원이 스크랩한 일정 모두 조회 // + List findAllByUserIdOrderByIdDesc(Long userId, Pageable page); + + List findByIdLessThanAndUserIdOrderByIdDesc(Long cursorId, Long userId, Pageable page); + + // #51 스크랩 삭제 전 삭제할 스크랩이 있는지 조회 + Optional findByPlanIdAndUserId(Long planId, Long userId); +} diff --git a/src/main/java/com/minizin/travel/scrap/service/ScrapService.java b/src/main/java/com/minizin/travel/scrap/service/ScrapService.java new file mode 100644 index 0000000..16088ce --- /dev/null +++ b/src/main/java/com/minizin/travel/scrap/service/ScrapService.java @@ -0,0 +1,165 @@ +package com.minizin.travel.scrap.service; + +import com.minizin.travel.plan.entity.Plan; +import com.minizin.travel.plan.entity.PlanSchedule; +import com.minizin.travel.plan.repository.PlanRepository; +import com.minizin.travel.plan.repository.PlanScheduleRepository; +import com.minizin.travel.plan.service.PlanService; +import com.minizin.travel.scrap.dto.*; +import com.minizin.travel.scrap.entity.Scrap; +import com.minizin.travel.scrap.repository.ScrapRepository; +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.UserErrorCode; +import com.minizin.travel.user.domain.exception.CustomUserException; +import com.minizin.travel.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ScrapService { + + private final ScrapRepository scrapRepository; + private final PlanRepository planRepository; + private final PlanScheduleRepository planScheduleRepository; + private final UserRepository userRepository; + private final PlanService planService; + + final int DEFAULT_PAGE_SIZE = 6; + + // #49 스크랩 생성 START // + public ResponseCreateScrapPlanDto createScrap(Long planId, + PrincipalDetails user) { + + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); + + // 해당 plan 이 DB에 저장되었는지 확인 + if (!planRepository.existsById(planId)) { + return ResponseCreateScrapPlanDto.fail(planId, "요청하신 plan 은 존재하지 않습니다."); + } + + // 해당 plan 을 이미 저장하지 않았는지 확인 + if (scrapRepository.existsByPlanIdAndUserId(planId, userId)) { + return ResponseCreateScrapPlanDto.fail(planId, "이미 북마크한 plan 입니다."); + } + + Plan plan = planRepository.findById(planId).get(); + + /* + if (plan.getUserId().equals(userId)) { + return ResponseCreateScrapPlanDto.fail(planId, "본인의 plan은 북마크할 수 없습니다."); + } + */ + + plan.setNumberOfScraps(plan.getNumberOfScraps() + 1); + + return ResponseCreateScrapPlanDto.toDto(scrapRepository.save(Scrap.builder() + .userId(userId) + .planId(planId) + .createdAt(LocalDateTime.now()) + .build())); + } + // #49 스크랩 생성 END // + + // #50 스크랩 조회 START // + public ResponseSelectScrapedPlansDto selectListScrapedPlans(Long cursorId, PrincipalDetails user) { + + Pageable page = PageRequest.of(0, DEFAULT_PAGE_SIZE); + + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); + + if (!scrapRepository.existsByUserId(userId)) { + return ResponseSelectScrapedPlansDto.fail("북마크한 plan 이 없습니다."); + } + + List scrapList = findAllByCursorIdCheckExistCursor(userId, cursorId, page); + List scrapedPlansDtoList = new ArrayList<>(); + Long scrapId = 0L; + for (Scrap scrap : scrapList) { + scrapId = scrap.getId(); + Plan plan = planRepository.findById(scrap.getPlanId()).get(); + + SelectScrapedPlansDto newSelectScrapedPlanDto = SelectScrapedPlansDto.toDto(plan); + newSelectScrapedPlanDto.setId(scrapId); + newSelectScrapedPlanDto.setUserNickname(userRepository.findById(plan.getUserId()).get().getNickname()); + + // 예산 계산 + List planScheduleList = planScheduleRepository.findAllByPlanId(plan.getId()); + newSelectScrapedPlanDto.setPlanBudget(planService.calculateTotalPlanBudget(planScheduleList)); + + scrapedPlansDtoList.add(newSelectScrapedPlanDto); + } + + if (!findAllByCursorIdCheckExistCursor(userId, scrapId, page).isEmpty()) { + scrapId = null; + } + + return ResponseSelectScrapedPlansDto.builder() + .data(scrapedPlansDtoList) + .cursorId(scrapId) + .build(); + } + + private List findAllByCursorIdCheckExistCursor(Long userId, Long cursorId, Pageable page) { + + return cursorId == 0 ? scrapRepository.findAllByUserIdOrderByIdDesc(userId, page) + : scrapRepository.findByIdLessThanAndUserIdOrderByIdDesc(cursorId, userId, page); + } + // #50 스크랩 조회 END // + + // #51 스크랩 삭제 START // + @Transactional + public ResponseDeleteScrapedPlanDto deleteScrapedPlan(Long planId, PrincipalDetails user) { + + Long userId = userRepository.findByUsername(user.getUsername()).get().getId(); + + if (!scrapRepository.existsByPlanIdAndUserId(planId, userId)) { + + return ResponseDeleteScrapedPlanDto.builder() + .success(false) + .message("존재하지 않는 scrap 입니다.") + .build(); + } + + Scrap scrap = scrapRepository.findByPlanIdAndUserId(planId, userId).get(); + + Plan plan = planRepository.findById(scrap.getPlanId()).get(); + plan.setNumberOfScraps(plan.getNumberOfScraps() - 1); + + scrapRepository.deleteById(scrap.getId()); + + return ResponseDeleteScrapedPlanDto.builder() + .success(true) + .message("Scrap Deleted Successfully") + .scrapId(scrap.getId()) + .build(); + } + // #51 스크랩 삭제 END // + + public ResponseCheckScrapedPlanDto checkScrapedPlan(Long planId, PrincipalDetails user) { + + UserEntity userEntity = userRepository.findByUsername(user.getUsername()) + .orElseThrow(() -> new CustomUserException(UserErrorCode.USER_NOT_FOUND)); + + if (!scrapRepository.existsByPlanIdAndUserId(planId, userEntity.getId())) { + + return ResponseCheckScrapedPlanDto.builder() + .success(false) + .message("존재하지 않는 scrap 입니다.") + .build(); + } + + return ResponseCheckScrapedPlanDto.builder() + .success(true) + .message("존재하는 scrap 입니다.") + .build(); + } +} diff --git a/src/main/java/com/minizin/travel/tour/controller/TourController.java b/src/main/java/com/minizin/travel/tour/controller/TourController.java new file mode 100644 index 0000000..b0ba330 --- /dev/null +++ b/src/main/java/com/minizin/travel/tour/controller/TourController.java @@ -0,0 +1,82 @@ +package com.minizin.travel.tour.controller; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.minizin.travel.tour.domain.dto.TourAPIDto; +import com.minizin.travel.tour.domain.entity.TourAPI; +import com.minizin.travel.tour.service.TourService; +import io.swagger.v3.oas.annotations.Operation; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Class: TourController Project: travel Package: com.minizin.travel.tour.controller + *

+ * Description: TourController + * + * @author dong-hoshin + * @date 6/8/24 03:35 Copyright (c) 2024 miniJin + * @see GitHub Repository + */ +//@Tag(name = "Tour Controller") +@Slf4j +@RestController +@RequestMapping("/tour") +@RequiredArgsConstructor +public class TourController { + private final TourService tourService; + Logger logger = LoggerFactory.getLogger(getClass()); + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + @Operation(summary = "Get detailCommon", description = "Retrieve a specific detailCommon ") + @GetMapping("/detailCommon") + public CompletableFuture> getAPITourDataDetailCommon(@ModelAttribute TourAPIDto.TourRequest requestParam) { + return tourService.getTourAPIFromSiteDetailCommon(requestParam) + .thenApply(response -> { + logger.info(response); // 성공 메시지를 로그에 출력 + return ResponseEntity.ok(response); + }); + } + + @Operation(summary = "Get areaBasedList", description = "Retrieve a specific areaBasedList ") + @GetMapping("/areaBasedList") + public CompletableFuture>> getAPITourDataAreaBasedList(@ModelAttribute TourAPIDto.TourRequest requestParam) { + return createResponseEntity(tourService.getTourAPIFromSiteAreaBasedList(requestParam)); + } + @Operation(summary = "Get areaBasedListSingle", description = "Retrieve a specific Single areaBasedList with batch") + @GetMapping("/areaBasedListSingle") + public CompletableFuture>> getAPITourDataAreaBasedListSingle(@ModelAttribute TourAPIDto.TourRequest requestParam) { + return createResponseEntity(tourService.getTourAPIFromSiteAreaBasedListSingle(requestParam)); + } + + + @Operation(summary = "Get areaCode", description = "Retrieve a specific areaBasedList by its ID") + @GetMapping("/areacode") + public CompletableFuture>> getAPITourDataAreaCode(@ModelAttribute TourAPIDto.TourRequest requestParam) { + return createResponseEntity(tourService.getTourAPIFromSiteAreaCode(requestParam)); + } + + @Operation(summary = "Get searchkeyword", description = "Retrieve a specific searchkeyword by keyword") + @GetMapping("/searchkeyword") + public CompletableFuture>> getTourAPIFromSiteSearchKeyword(@ModelAttribute TourAPIDto.TourRequest requestParam) { + return createResponseEntity(tourService.getTourAPIFromSiteSearchKeyword(requestParam)); + } + + private CompletableFuture>> createResponseEntity(CompletableFuture> future) { + return future.thenApply(ResponseEntity::ok) + .exceptionally(throwable -> ResponseEntity.status(500).build()); + } + +} \ No newline at end of file diff --git a/src/main/java/com/minizin/travel/tour/controller/TourInfoController.java b/src/main/java/com/minizin/travel/tour/controller/TourInfoController.java new file mode 100644 index 0000000..eb2a89e --- /dev/null +++ b/src/main/java/com/minizin/travel/tour/controller/TourInfoController.java @@ -0,0 +1,132 @@ +package com.minizin.travel.tour.controller; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.minizin.travel.tour.domain.dto.TourAPIDto; +import com.minizin.travel.tour.service.TourInfoService; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Class: TourInfoController Project: travel Package: com.minizin.travel.tour.controller + *

+ * Description: TourInfoController + * + * @author dong-hoshin + * @date 6/16/24 13:49 Copyright (c) 2024 MiniJin + * @see GitHub Repository + */ +@Slf4j +@RestController +@RequestMapping("/tour/info") +@RequiredArgsConstructor +public class TourInfoController { + private final TourInfoService tourInfoService; + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + @GetMapping("/areaCode1") + public ResponseEntity getTourDataByAreaCode(@ModelAttribute TourAPIDto.TourRequest requestUrl) throws IOException { + log.info("Received request: {}", requestUrl); + return processTourRequest(requestUrl, () -> { + try { + return tourInfoService.getTourDataByAreaCode(requestUrl); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @GetMapping("/areaBasedList1") + public ResponseEntity getTourDataByBasedList(@ModelAttribute TourAPIDto.TourRequest requestUrl) throws IOException { + log.info("Received request: {}", requestUrl); + try { + return processTourRequest(requestUrl, () -> { + try { + return tourInfoService.getTourDataByAreaBasedList(requestUrl); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } catch (RuntimeException e) { + return handleException(e); + } + } + + @GetMapping("/searchKeyword1") + public ResponseEntity getTourDataBySearchKeyword(@ModelAttribute TourAPIDto.TourRequest requestUrl) throws IOException { + log.info("Received request: {}", requestUrl); + try { + return processTourRequest(requestUrl, () -> { + try { + return tourInfoService.getTourDataSearchKeyword(requestUrl); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } catch (RuntimeException e) { + return handleException(e); + } + } + + @GetMapping("/detailCommon1") + public ResponseEntity getTourDataByDetailCommon(@ModelAttribute TourAPIDto.TourRequest requestUrl) throws IOException { + log.info("Received request: {}", requestUrl); + try { + return processTourRequest(requestUrl, () -> { + try { + return tourInfoService.getTourDataByDetailCommon(requestUrl); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } catch (RuntimeException e) { + return handleException(e); + } + } + + private ResponseEntity processTourRequest(TourAPIDto.TourRequest requestUrl, Supplier tourDataSupplier) { + try { + if (requestUrl.getServiceKey() != null ) { + TourAPIDto responseDto = tourDataSupplier.get(); + String jsonResponse = gson.toJson(responseDto); + log.info(jsonResponse); + return ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(jsonResponse); + } else { + String errorResponse = "{\"successful\":false,\"redirect\":false}"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(errorResponse); + } + } catch (RuntimeException e) { + throw e; + } + } + + private ResponseEntity handleException(RuntimeException e) { + String errorResponse; + if (e.getCause() instanceof IOException) { + errorResponse = "{\"successful\":false,\"error\":\"IOException occurred\"}"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(errorResponse); + } else { + errorResponse = "{\"successful\":false,\"error\":\"Unexpected error occurred\"}"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(errorResponse); + } + } +} diff --git a/src/main/java/com/minizin/travel/tour/domain/dto/TourAPIDto.java b/src/main/java/com/minizin/travel/tour/domain/dto/TourAPIDto.java new file mode 100644 index 0000000..f6f926e --- /dev/null +++ b/src/main/java/com/minizin/travel/tour/domain/dto/TourAPIDto.java @@ -0,0 +1,238 @@ +package com.minizin.travel.tour.domain.dto; + +import com.minizin.travel.tour.domain.entity.TourAPI; +import jakarta.persistence.Column; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Class: TourAPIRequestDTO Project: travel Package: com.minizin.travel.tour.domain.dto + *

+ * Description: TourAPIRequestDTO + * + * @author dong-hoshin + * @date 6/3/24 19:56 Copyright (c) 2024 MiniJin + * @see GitHub Repository + */ +@Data +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TourAPIDto { + private TourRequest request; + @Data + @Getter + @Setter + public static class TourRequest { + private String MobileOS; + private String MobileApp; + private String _type; + private String ServiceKey; + + private String areaCode; + private String arrange; + private String cat1; + private String cat2; + private String cat3; + private String contentTypeId; + private String contentId; + private String eventStartDate; + private String keyword; + private String listYN; + private String mapX; + private String mapY; + private String modifiedtime; + private String numOfRows; + private String pageNo; + private String radius; + private String sigunguCode; + private String defaultYN; + private String firstImageYN; + private String areacodeYN; + private String catcodeYN; + private String addrinfoYN; + private String mapinfoYN; + private String overviewYN; + } + + private TourResponse response; + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class TourResponse { + private Header header; + private Body body; + + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Header { + private String resultCode; + private String resultMsg; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Body { + private Items items; + private int numOfRows; + private int pageNo; + private int totalCount; + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Items { + private List item; + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Item { + private String addr1; + private String addr2; + private String areacode; + private String benikia; + private String booktour; + private String cat1; + private String cat2; + private String cat3; + private String code; + private String contentid; + private String contenttypeid; + private String cpyrhtDivCd; + private String createdtime; + private String dist; + private String eventstartdate; + private String firstimage; + private String firstimage2; + private String goodstay; + private String hanok; + private String mapx; + private String mapy; + private String mlevel; + private String modifiedtime; + private String name; + private Integer numOfRows; + private Integer pageNo; + private Integer rnum; + private String sigungucode; + private String tel; + private String title; + private Integer totalCnt; + private Integer totalCount; + private String zipcode; + @Column(columnDefinition = "TEXT") + private String homepage; + private String telname; + @Column(columnDefinition = "TEXT") + private String overview; + private String readcount; + + public TourAPI toEntity() { + return TourAPI.builder() + .addr1(Optional.ofNullable(addr1).orElse("")) + .addr2(Optional.ofNullable(addr2).orElse("")) + .areaCode(Optional.ofNullable(areacode).orElse("")) + .benikia(Optional.ofNullable(benikia).orElse("")) + .booktour(Optional.ofNullable(booktour).orElse("")) + .cat1(Optional.ofNullable(cat1).orElse("")) + .cat2(Optional.ofNullable(cat2).orElse("")) + .cat3(Optional.ofNullable(cat3).orElse("")) + .code(Optional.ofNullable(code).orElse("")) + .contentId(Optional.ofNullable(contentid).orElse("")) + .contentTypeId(Optional.ofNullable(contenttypeid).orElse("")) + .cpyrhtDivCd(Optional.ofNullable(cpyrhtDivCd).orElse("")) + .createdTime(Optional.ofNullable(createdtime).orElse("")) + .dist(Optional.ofNullable(dist).orElse("")) + .eventStartDate(Optional.ofNullable(eventstartdate).orElse("")) + .firstImage(Optional.ofNullable(firstimage).orElse("")) + .firstImage2(Optional.ofNullable(firstimage2).orElse("")) + .goodStay(Optional.ofNullable(goodstay).orElse("")) + .hanok(Optional.ofNullable(hanok).orElse("")) + .mapX(Optional.ofNullable(mapx).orElse("")) + .mapY(Optional.ofNullable(mapy).orElse("")) + .mlevel(Optional.ofNullable(mlevel).orElse("")) + .modifiedTime(Optional.ofNullable(modifiedtime).orElse("")) + .name(Optional.ofNullable(name).orElse("")) + .numOfRows(Optional.ofNullable(numOfRows).orElse(0)) + .pageNo(Optional.ofNullable(pageNo).orElse(0)) + .rnum(Optional.ofNullable(rnum).orElse(0)) + .sigunguCode(Optional.ofNullable(sigungucode).orElse("")) + .tel(Optional.ofNullable(tel).orElse("")) + .title(Optional.ofNullable(title).orElse("")) + .totalCnt(Optional.ofNullable(totalCnt).orElse(0)) + .totalCount(Optional.ofNullable(totalCount).orElse(0)) + .zipcode(Optional.ofNullable(zipcode).orElse("")) + .homepage(Optional.ofNullable(homepage).orElse("")) + .telName(Optional.ofNullable(telname).orElse("")) + .overview(Optional.ofNullable(overview).orElse("")) + .build(); + } + public TourAPI toEntitySigungu(String codeArea,boolean codeOrSigungu) { + // areaCode 가 비어있을 때 모든 특별시, 도를 검색 (true) + // areaCode가 있을 때는 해당 areaCode의 sigungu를 검색 (false) + // !codeOrSigungu -> areaCode가 있을 때 sigungucode에 code를 넣고 code에 areaCode를 넣는다. + if (!codeOrSigungu) { + sigungucode = code; + code = codeArea; + } + return TourAPI.builder() + .code(Optional.ofNullable(code).orElse("")) + .sigunguCode(Optional.ofNullable(sigungucode).orElse("")) + .rnum(Optional.ofNullable(rnum).orElse(0)) + .totalCnt(Optional.ofNullable(totalCnt).orElse(0)) + .totalCount(Optional.ofNullable(totalCount).orElse(0)) + .name(Optional.ofNullable(name).orElse("")) + .build(); + } + } + } + } + } + + public List toEntityList() { + List tourAPIList = new ArrayList<>(); + if (response != null && response.getBody() != null && response.getBody().getItems() != null) { + for (TourResponse.Body.Items.Item item : response.getBody().getItems().getItem()) { + TourAPI tourAPI = item.toEntity(); + tourAPIList.add(tourAPI); + } + } + return tourAPIList; + } + + public List toEntityListArea(String codeArea,boolean codeOrSigungu) { + List tourAPIList = new ArrayList<>(); + if (response != null && response.getBody() != null && response.getBody().getItems() != null) { + for (TourResponse.Body.Items.Item item : response.getBody().getItems().getItem()) { + TourAPI tourAPI = item.toEntitySigungu(codeArea, codeOrSigungu); + + tourAPIList.add(tourAPI); + } + } + return tourAPIList; + } +} diff --git a/src/main/java/com/minizin/travel/tour/domain/dto/TourAPIRequestDTO.java b/src/main/java/com/minizin/travel/tour/domain/dto/TourAPIRequestDTO.java deleted file mode 100644 index 30ff444..0000000 --- a/src/main/java/com/minizin/travel/tour/domain/dto/TourAPIRequestDTO.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.minizin.travel.tour.domain.dto; - -/** - * Class: TourAPIRequestDTO Project: travel Package: com.minizin.travel.tour.domain.dto - *

- * Description: TourAPIRequestDTO - * - * @author dong-hoshin - * @date 6/3/24 19:56 Copyright (c) 2024 MiniJin - * @see GitHub Repository - */ -public class TourAPIRequestDTO { - private Long requestId; - private String type; - private String areaCode; - private String arrange; - private String cat1; - private String cat2; - private String cat3; - private String contentTypeId; - private String eventStartDate; - private String keyword; - private String listYN; - private String mapX; - private String mapY; - private String mobileApp; - private String mobileOS; - private String modifiedTime; - private Integer numOfRows; - private Integer pageNo; - private String radius; - private String serviceKey; - private String sigunguCode; - - -} diff --git a/src/main/java/com/minizin/travel/tour/domain/dto/TourAPIResponseDTO.java b/src/main/java/com/minizin/travel/tour/domain/dto/TourAPIResponseDTO.java deleted file mode 100644 index 3f7361a..0000000 --- a/src/main/java/com/minizin/travel/tour/domain/dto/TourAPIResponseDTO.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.minizin.travel.tour.domain.dto; - -/** - * Class: TourAPIResponseDTO Project: travel Package: com.minizin.travel.tour.domain.dto - *

- * Description: TourAPIResponseDTO - * - * @author dong-hoshin - * @date 6/3/24 19:58 Copyright (c) 2024 MiniJin - * @see GitHub Repository - */ -public class TourAPIResponseDTO { - private Long responseId; - private String addr1; - private String addr2; - private String areaCode; - private String benikia; - private String booktour; - private String cat1; - private String cat2; - private String cat3; - private String code; - private String contentId; - private String contentTypeId; - private String cpyrhtDivCd; - private String createdTime; - private String dist; - private String eventStartDate; - private String firstImage; - private String firstImage2; - private String goodStay; - private String hanok; - private String mapx; - private String mapy; - private String mlevel; - private String modifiedTime; - private String name; - private Integer numOfRows; - private Integer pageNo; - private String resultCode; - private String resultMsg; - private String rnum; - private String sigunguCode; - private String tel; - private String title; - private Integer totalCnt; - private Integer totalCount; - private String zipcode; - -} diff --git a/src/main/java/com/minizin/travel/tour/domain/entity/TourAPI.java b/src/main/java/com/minizin/travel/tour/domain/entity/TourAPI.java new file mode 100644 index 0000000..0b3faa5 --- /dev/null +++ b/src/main/java/com/minizin/travel/tour/domain/entity/TourAPI.java @@ -0,0 +1,278 @@ +package com.minizin.travel.tour.domain.entity; + +import com.minizin.travel.tour.domain.dto.TourAPIDto; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Class: TourAPIRequest Project: travel Package: com.minizin.travel.tour.domain.entity + *

+ * Description: TourAPIRequest + * + * @author dong-hoshin + * @date 6/3/24 19:23 Copyright (c) 2024 MiniJin + * @see GitHub Repository + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "tour_api") +public class TourAPI { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "tour_id") + private Long tourId; + + @Column(name = "content_id") + private String contentId; + + @Column(name = "type") + private String _type; + + @Column(name = "area_code") + private String areaCode; + + @Column(name = "arrange") + private String arrange; + + @Column(name = "cat1") + private String cat1; + + @Column(name = "cat2") + private String cat2; + + @Column(name = "cat3") + private String cat3; + + @Column(name = "content_type_id") + private String contentTypeId; + + @Column(name = "event_start_date") + private String eventStartDate; + + @Column(name = "keyword") + private String keyword; + + @Column(name = "list_yn") + private String listYn; + + @Column(name = "map_x") + private String mapX; + + @Column(name = "map_y") + private String mapY; + + @Column(name = "mobile_app") + private String mobileApp; + + @Column(name = "mobile_os") + private String mobileOs; + + @Column(name = "modified_time") + private String modifiedTime; + + @Column(name = "num_of_rows") + private Integer numOfRows; + + @Column(name = "page_no") + private Integer pageNo; + + @Column(name = "radius") + private String radius; + + @Column(name = "service_key") + private String serviceKey; + + @Column(name = "sigungu_code") + private String sigunguCode; + + @Column(name = "default_yn") + private String defaultYn; + + @Column(name = "first_image_yn") + private String firstImageYn; + + @Column(name = "area_code_yn") + private String areaCodeYn; + + @Column(name = "cat_code_yn") + private String catCodeYn; + + @Column(name = "addr_info_yn") + private String addrInfoYn; + + @Column(name = "map_info_yn") + private String mapInfoYn; + + @Column(name = "overview_yn") + private String overviewYn; + + @Column(name = "addr1") + private String addr1; + + @Column(name = "addr2") + private String addr2; + + @Column(name = "benikia") + private String benikia; + + @Column(name = "booktour") + private String booktour; + + @Column(name = "code") + private String code; + + @Column(name = "cpyrht_div_cd") + private String cpyrhtDivCd; + + @Column(name = "created_time") + private String createdTime; + + @Column(name = "dist") + private String dist; + + @Column(name = "first_image") + private String firstImage; + + @Column(name = "first_image2") + private String firstImage2; + + @Column(name = "good_stay") + private String goodStay; + + @Column(name = "hanok") + private String hanok; + + @Column(name = "mlevel") + private String mlevel; + + @Column(name = "name") + private String name; + + @Column(name = "rnum") + private Integer rnum; + + @Column(name = "tel") + private String tel; + + @Column(name = "title") + private String title; + + @Column(name = "total_cnt") + private int totalCnt; + + @Column(name = "total_count") + private int totalCount; + + @Column(name = "zipcode") + private String zipcode; + + @Column(name = "homepage", columnDefinition = "TEXT") + private String homepage; + + @Column(name = "tel_name") + private String telName; + + @Column(name = "overview", columnDefinition = "TEXT") + private String overview; + + public TourAPIDto.TourResponse.Body.Items.Item toDto() { + return TourAPIDto.TourResponse.Body.Items.Item.builder() + .addr1(Optional.ofNullable(this.getAddr1()).orElse("")) + .addr2(Optional.ofNullable(this.getAddr2()).orElse("")) + .areacode(Optional.ofNullable(this.getAreaCode()).orElse("")) + .benikia(Optional.ofNullable(this.getBenikia()).orElse("")) + .booktour(Optional.ofNullable(this.getBooktour()).orElse("")) + .cat1(Optional.ofNullable(this.getCat1()).orElse("")) + .cat2(Optional.ofNullable(this.getCat2()).orElse("")) + .cat3(Optional.ofNullable(this.getCat3()).orElse("")) + .code(Optional.ofNullable(this.getCode()).orElse("")) + .contentid(Optional.ofNullable(this.getContentId()).orElse("")) + .contenttypeid(Optional.ofNullable(this.getContentTypeId()).orElse("")) + .cpyrhtDivCd(Optional.ofNullable(this.getCpyrhtDivCd()).orElse("")) + .createdtime(Optional.ofNullable(this.getCreatedTime()).orElse("")) + .dist(Optional.ofNullable(this.getDist()).orElse("")) + .eventstartdate(Optional.ofNullable(this.getEventStartDate()).orElse("")) + .firstimage(Optional.ofNullable(this.getFirstImage()).orElse("")) + .firstimage2(Optional.ofNullable(this.getFirstImage2()).orElse("")) + .goodstay(Optional.ofNullable(this.getGoodStay()).orElse("")) + .hanok(Optional.ofNullable(this.getHanok()).orElse("")) + .mapx(Optional.ofNullable(this.getMapX()).orElse("")) + .mapy(Optional.ofNullable(this.getMapY()).orElse("")) + .mlevel(Optional.ofNullable(this.getMlevel()).orElse("")) + .modifiedtime(Optional.ofNullable(this.getModifiedTime()).orElse("")) + .name(Optional.ofNullable(this.getName()).orElse("")) + .numOfRows(Optional.ofNullable(this.getNumOfRows()).orElse(0)) + .pageNo(Optional.ofNullable(this.getPageNo()).orElse(0)) + .rnum(Optional.ofNullable(this.getRnum()).orElse(0)) + .sigungucode(Optional.ofNullable(this.getSigunguCode()).orElse("")) + .tel(Optional.ofNullable(this.getTel()).orElse("")) + .title(Optional.ofNullable(this.getTitle()).orElse("")) + .totalCnt(Optional.ofNullable(this.getTotalCnt()).orElse(0)) + .totalCount(Optional.ofNullable(this.getTotalCount()).orElse(0)) + .zipcode(Optional.ofNullable(this.getZipcode()).orElse("")) + .homepage(Optional.ofNullable(this.getHomepage()).orElse("")) + .telname(Optional.ofNullable(this.getTelName()).orElse("")) + .overview(Optional.ofNullable(this.getOverview()).orElse("")) + .build(); + } + + private static final ExecutorService threadPool = Executors.newCachedThreadPool(); + public CompletableFuture updateFromDtoAsync(TourAPIDto.TourResponse.Body.Items.Item dto) { + return CompletableFuture.runAsync(() -> { + if (dto.getAddr1() != null) this.addr1 = dto.getAddr1(); + if (dto.getAddr2() != null) this.addr2 = dto.getAddr2(); + if (dto.getAreacode() != null) this.areaCode = dto.getAreacode(); + if (dto.getBenikia() != null) this.benikia = dto.getBenikia(); + if (dto.getBooktour() != null) this.booktour = dto.getBooktour(); + if (dto.getCat1() != null) this.cat1 = dto.getCat1(); + if (dto.getCat2() != null) this.cat2 = dto.getCat2(); + if (dto.getCat3() != null) this.cat3 = dto.getCat3(); + if (dto.getCode() != null) this.code = dto.getCode(); + if (dto.getContentid() != null) this.contentId = dto.getContentid(); + if (dto.getContenttypeid() != null) this.contentTypeId = dto.getContenttypeid(); + if (dto.getCpyrhtDivCd() != null) this.cpyrhtDivCd = dto.getCpyrhtDivCd(); + if (dto.getCreatedtime() != null) this.createdTime = dto.getCreatedtime(); + if (dto.getDist() != null) this.dist = dto.getDist(); + if (dto.getEventstartdate() != null) this.eventStartDate = dto.getEventstartdate(); + if (dto.getFirstimage() != null) this.firstImage = dto.getFirstimage(); + if (dto.getFirstimage2() != null) this.firstImage2 = dto.getFirstimage2(); + if (dto.getGoodstay() != null) this.goodStay = dto.getGoodstay(); + if (dto.getHanok() != null) this.hanok = dto.getHanok(); + if (dto.getMapx() != null) this.mapX = dto.getMapx(); + if (dto.getMapy() != null) this.mapY = dto.getMapy(); + if (dto.getMlevel() != null) this.mlevel = dto.getMlevel(); + if (dto.getModifiedtime() != null) this.modifiedTime = dto.getModifiedtime(); + if (dto.getName() != null) this.name = dto.getName(); + if (dto.getNumOfRows() != null) this.numOfRows = dto.getNumOfRows(); + if (dto.getPageNo() != null) this.pageNo = dto.getPageNo(); + if (dto.getRnum() != null) this.rnum = dto.getRnum(); + if (dto.getSigungucode() != null) this.sigunguCode = dto.getSigungucode(); + if (dto.getTel() != null) this.tel = dto.getTel(); + if (dto.getTitle() != null) this.title = dto.getTitle(); + if (dto.getTotalCnt() != null) this.totalCnt = dto.getTotalCnt(); + if (dto.getTotalCount() != null) this.totalCount = dto.getTotalCount(); + if (dto.getZipcode() != null) this.zipcode = dto.getZipcode(); + if (dto.getHomepage() != null) this.homepage = dto.getHomepage(); + if (dto.getTelname() != null) this.telName = dto.getTelname(); + if (dto.getOverview() != null) this.overview = dto.getOverview(); + }, threadPool); + } +} \ No newline at end of file diff --git a/src/main/java/com/minizin/travel/tour/domain/entity/TourAPIRequest.java b/src/main/java/com/minizin/travel/tour/domain/entity/TourAPIRequest.java deleted file mode 100644 index 09acea5..0000000 --- a/src/main/java/com/minizin/travel/tour/domain/entity/TourAPIRequest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.minizin.travel.tour.domain.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; - -/** - * Class: TourAPIRequest Project: travel Package: com.minizin.travel.tour.domain.entity - *

- * Description: TourAPIRequest - * - * @author dong-hoshin - * @date 6/3/24 19:23 Copyright (c) 2024 MiniJin - * @see GitHub Repository - */ -@Entity -@Table(name = "tour_api_request") -public class TourAPIRequest { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "request_id", nullable = false) - private Long requestId; - - @Column(name = "_type") - private String type; - - @Column(name = "area_code") - private String areaCode; - - @Column(name = "arrange") - private String arrange; - - @Column(name = "cat1") - private String cat1; - - @Column(name = "cat2") - private String cat2; - - @Column(name = "cat3") - private String cat3; - - @Column(name = "content_type_id") - private String contentTypeId; - - @Column(name = "event_start_date") - private String eventStartDate; - - @Column(name = "keyword") - private String keyword; - - @Column(name = "list_yn") - private String listYN; - - @Column(name = "map_x") - private String mapX; - - @Column(name = "map_y") - private String mapY; - - @Column(name = "mobile_app") - private String mobileApp; - - @Column(name = "mobile_os") - private String mobileOS; - - @Column(name = "modified_time") - private String modifiedTime; - - @Column(name = "num_of_rows") - private Integer numOfRows; - - @Column(name = "page_no") - private Integer pageNo; - - @Column(name = "radius") - private String radius; - - @Column(name = "service_key") - private String serviceKey; - - @Column(name = "sigungu_code") - private String sigunguCode; - -} diff --git a/src/main/java/com/minizin/travel/tour/domain/entity/TourAPIResponse.java b/src/main/java/com/minizin/travel/tour/domain/entity/TourAPIResponse.java deleted file mode 100644 index 2811025..0000000 --- a/src/main/java/com/minizin/travel/tour/domain/entity/TourAPIResponse.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.minizin.travel.tour.domain.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; - -/** - * Class: TourAPIResponse Project: travel Package: com.minizin.travel.tour.domain.entity - *

- * Description: TourAPIResponse - * - * @author dong-hoshin - * @date 6/3/24 19:46 Copyright (c) 2024 MiniJin - * @see GitHub Repository - */ - -@Entity -@Table(name = "tour_api_response") -public class TourAPIResponse { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "response_id", nullable = false) - private Long responseId; - - @Column(name = "addr1") - private String addr1; - - @Column(name = "addr2") - private String addr2; - - @Column(name = "area_code") - private String areaCode; - - @Column(name = "benikia") - private String benikia; - - @Column(name = "booktour") - private String booktour; - - @Column(name = "cat1") - private String cat1; - - @Column(name = "cat2") - private String cat2; - - @Column(name = "cat3") - private String cat3; - - @Column(name = "code") - private String code; - - @Column(name = "content_id") - private String contentId; - - @Column(name = "content_type_id") - private String contentTypeId; - - @Column(name = "cpyrht_div_cd") - private String cpyrhtDivCd; - - @Column(name = "created_time") - private String createdTime; - - @Column(name = "dist") - private String dist; - - @Column(name = "event_start_date") - private String eventStartDate; - - @Column(name = "first_image") - private String firstImage; - - @Column(name = "first_image2") - private String firstImage2; - - @Column(name = "good_stay") - private String goodStay; - - @Column(name = "hanok") - private String hanok; - - @Column(name = "mapx") - private String mapx; - - @Column(name = "mapy") - private String mapy; - - @Column(name = "mlevel") - private String mlevel; - - @Column(name = "modified_time") - private String modifiedTime; - - @Column(name = "name") - private String name; - - @Column(name = "num_of_rows") - private Integer numOfRows; - - @Column(name = "page_no") - private Integer pageNo; - - @Column(name = "result_code") - private String resultCode; - - @Column(name = "result_msg") - private String resultMsg; - - @Column(name = "rnum") - private String rnum; - - @Column(name = "sigungu_code") - private String sigunguCode; - - @Column(name = "tel") - private String tel; - - @Column(name = "title") - private String title; - - @Column(name = "total_cnt") - private Integer totalCnt; - - @Column(name = "total_count") - private Integer totalCount; - - @Column(name = "zipcode") - private String zipcode; - -} diff --git a/src/main/java/com/minizin/travel/tour/domain/repository/TourAPIRepository.java b/src/main/java/com/minizin/travel/tour/domain/repository/TourAPIRepository.java new file mode 100644 index 0000000..dace5d2 --- /dev/null +++ b/src/main/java/com/minizin/travel/tour/domain/repository/TourAPIRepository.java @@ -0,0 +1,55 @@ +package com.minizin.travel.tour.domain.repository; + +import com.minizin.travel.tour.domain.dto.TourAPIDto; +import com.minizin.travel.tour.domain.entity.TourAPI; +import io.lettuce.core.dynamic.annotation.Param; +import jakarta.persistence.Tuple; +import java.util.List; +import java.util.Optional; +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; + +/** + * Class: TourAPIRequestRepository Project: travel Package: + * com.minizin.travel.tour.domain.repository + *

+ * Description: TourAPIRequestRepository + * + * @author dong-hoshin + * @date 6/3/24 20:00 Copyright (c) 2024 MiniJin + * @see GitHub Repository + */ +@Repository +public interface TourAPIRepository extends JpaRepository { + + // TourAPI 에서 중복 값 없이(DISTINCT) Null 값 & 빈 값('') 제외하고 가져오기. + @Query(value = "SELECT DISTINCT t FROM TourAPI t WHERE t.code IS NOT NULL AND t.code != '' GROUP BY t.code,t.sigunguCode") + List findDistinctAreaCode(); + + @Query("SELECT DISTINCT t FROM TourAPI t WHERE t.areaCode = :areaCode GROUP BY t.contentId") + List findDistinctAreaBasedList(@Param("areaCode") String areaCode); + + /*@Query("SELECT DISTINCT t FROM TourAPI t WHERE " + + "(t.areaCode = :areaCode) OR " + + "(t.contentTypeId = :contentTypeId) OR " + + "(t.sigunguCode = :sigunguCode) " ) + List findDistinctSearchKeyword(@Param("areaCode") String areaCode, + @Param("contentTypeId") String contentTypeId, + @Param("sigunguCode") String sigunguCode, + Pageable pageable);*/ + + @Query("SELECT DISTINCT t FROM TourAPI t WHERE t.addr1 LIKE CONCAT('%', :keyword, '%') OR t.title LIKE CONCAT('%', :keyword, '%') GROUP BY t.contentId ORDER BY t.areaCode,t.sigunguCode,t.contentTypeId" ) + List findDistinctSearchKeyword(@Param("keyword") String keyword); + + @Query("SELECT DISTINCT t FROM TourAPI t WHERE t.contentId IS NOT NULL AND t.contentId != '' AND t.overview = '' GROUP BY t.contentId") + Page findAll(Pageable pageable); + + @Query("SELECT DISTINCT t FROM TourAPI t WHERE t.contentId = :contentId GROUP BY t.contentId") + List findByContentId(@Param("contentId") String contentId); + @Query("SELECT DISTINCT t FROM TourAPI t WHERE t.contentId IS NOT NULL AND t.contentId != '' GROUP BY t.contentId") + List findAllList(); + +} diff --git a/src/main/java/com/minizin/travel/tour/domain/repository/TourAPIRequestRepository.java b/src/main/java/com/minizin/travel/tour/domain/repository/TourAPIRequestRepository.java deleted file mode 100644 index c2ef3a2..0000000 --- a/src/main/java/com/minizin/travel/tour/domain/repository/TourAPIRequestRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.minizin.travel.tour.domain.repository; - -import com.minizin.travel.tour.domain.entity.TourAPIRequest; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -/** - * Class: TourAPIRequestRepository Project: travel Package: - * com.minizin.travel.tour.domain.repository - *

- * Description: TourAPIRequestRepository - * - * @author dong-hoshin - * @date 6/3/24 20:00 Copyright (c) 2024 MiniJin - * @see GitHub Repository - */ -@Repository -public interface TourAPIRequestRepository extends JpaRepository { - Optional findByRequestId(Long RequestId); - -} diff --git a/src/main/java/com/minizin/travel/tour/domain/repository/TourAPIResponseRepository.java b/src/main/java/com/minizin/travel/tour/domain/repository/TourAPIResponseRepository.java deleted file mode 100644 index 83b1551..0000000 --- a/src/main/java/com/minizin/travel/tour/domain/repository/TourAPIResponseRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.minizin.travel.tour.domain.repository; - -import com.minizin.travel.tour.domain.entity.TourAPIResponse; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -/** - * Class: TourAPIResponseRepository Project: travel Package: - * com.minizin.travel.tour.domain.repository - *

- * Description: TourAPIResponseRepository - * - * @author dong-hoshin - * @date 6/3/24 20:03 Copyright (c) 2024 MiniJin - * @see GitHub Repository - */ -@Repository -public interface TourAPIResponseRepository extends JpaRepository { - Optional findByResponseId(Long ResponseId); -} diff --git a/src/main/java/com/minizin/travel/tour/service/TourInfoService.java b/src/main/java/com/minizin/travel/tour/service/TourInfoService.java new file mode 100644 index 0000000..34958fd --- /dev/null +++ b/src/main/java/com/minizin/travel/tour/service/TourInfoService.java @@ -0,0 +1,224 @@ +package com.minizin.travel.tour.service; + +import com.minizin.travel.tour.domain.dto.TourAPIDto; +import com.minizin.travel.tour.domain.entity.TourAPI; +import com.minizin.travel.tour.domain.repository.TourAPIRepository; +import java.io.IOException; +import java.text.Collator; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Class: TourInfoService Project: travel Package: com.minizin.travel.tour.service + *

+ * Description: TourInfoService + * + * @author dong-hoshin + * @date 6/16/24 13:49 Copyright (c) 2024 MiniJin + * @see GitHub Repository + */ +@Service +@RequiredArgsConstructor +public class TourInfoService { + + private final TourAPIRepository tourAPIRepository; + + public TourAPIDto getTourDataByAreaCode(TourAPIDto.TourRequest requestUrl) throws IOException { + int pageNo = 0; + String areaCode = Optional.ofNullable(requestUrl.getAreaCode()).orElse(""); + + // 데이터베이스에서 중복 제거된 데이터 가져오기 + List rawEntities = tourAPIRepository.findDistinctAreaCode(); + int numOfRows = 100; + + List rawItems = rawEntities.stream() + .filter(tourAPI -> { + // areaCode 있을 때 (sigunguCode가 존재 && areaCode와 동일한 Code) Data만 가져오기. + if (!areaCode.isEmpty()) { + return !tourAPI.getSigunguCode().isEmpty() && tourAPI.getCode().equals(areaCode); + //areaCode가 없을 때 (sigunguCode가 없는) data가져오기. + } else { + return tourAPI.getSigunguCode().isEmpty(); + } + }) + .map(TourAPI::toDto) + .collect(Collectors.toList()); + + // 응답 객체 생성 + TourAPIDto responseDto = buildResponseWithTourAPIDto(rawItems, pageNo,numOfRows); + + return responseDto; + } + + public TourAPIDto getTourDataByDetailCommon(TourAPIDto.TourRequest requestUrl) throws IOException { + String pageNo = Optional.ofNullable(requestUrl.getPageNo()).orElse("0"); + String numOfRows = Optional.ofNullable(requestUrl.getNumOfRows()).orElse("10"); + String contentId = Optional.ofNullable(requestUrl.getContentId()).orElse(""); + + int page = Integer.parseInt(pageNo); + int size = Integer.parseInt(numOfRows); + + List rawEntities; + // 데이터베이스에서 중복 제거된 데이터 가져오기 + // contentId가 있을 때는 contentId 기반으로 가져오고 + // 없을 때는 모든 자료를 가져오기. + if (contentId != "") { + rawEntities = tourAPIRepository.findByContentId(contentId); + } else { + rawEntities = tourAPIRepository.findAllList(); + } + + List rawItems = rawEntities.stream() + .map(TourAPI::toDto) + .collect(Collectors.toList()); + + List sortedItems = sortItemsByTitle(rawItems); + + TourAPIDto responseDto = buildResponseWithTourAPIDto(sortedItems, page, size); + + return responseDto; + } + + public TourAPIDto getTourDataByAreaBasedList(TourAPIDto.TourRequest requestUrl) throws IOException { + String pageNo = Optional.ofNullable(requestUrl.getPageNo()).orElse("0"); + String numOfRows = Optional.ofNullable(requestUrl.getNumOfRows()).orElse("10"); + String areaCode = Optional.ofNullable(requestUrl.getAreaCode()).orElse(""); + String contentTypeId = Optional.ofNullable(requestUrl.getContentTypeId()).orElse(""); + String sigunguCode = Optional.ofNullable(requestUrl.getSigunguCode()).orElse(""); + + int page = Integer.parseInt(pageNo); + int size = Integer.parseInt(numOfRows); + List rawEntities; + + // 데이터베이스에서 중복 제거된 데이터 가져오기 + // areaCode가 있을 때는 areaCode기반으로 가져오고 + // 없을 때는 모든 자료를 가져오기. + if (areaCode != "") { + rawEntities = tourAPIRepository.findDistinctAreaBasedList(areaCode); + } else { + rawEntities = tourAPIRepository.findAllList(); + } + List rawItems = rawEntities.stream() + .filter(tourAPI -> + // 변수값이 비어있을 때는 오른쪽 filter조건을 건너뜀 + // 변수값이 비어있지 않은 조건들을 모두 만족 시킨 데이터만 필터링함. + (areaCode.isEmpty() || tourAPI.getAreaCode().equals(areaCode)) && + (contentTypeId.isEmpty() || tourAPI.getContentTypeId().equals(contentTypeId)) && + (sigunguCode.isEmpty() || tourAPI.getSigunguCode().equals(sigunguCode)) + ) + .map(TourAPI::toDto) + .collect(Collectors.toList()); + + List sortedItems = sortItemsByTitle(rawItems); + + TourAPIDto responseDto = buildResponseWithTourAPIDto(sortedItems, page, size); + + return responseDto; + } + + public TourAPIDto getTourDataSearchKeyword(TourAPIDto.TourRequest requestUrl) throws IOException { + String pageNo = Optional.ofNullable(requestUrl.getPageNo()).orElse("0"); + String numOfRows = Optional.ofNullable(requestUrl.getNumOfRows()).orElse("10"); + String keyword = Optional.ofNullable(requestUrl.getKeyword()).orElse(""); + String areaCode = Optional.ofNullable(requestUrl.getAreaCode()).orElse(""); + String contentTypeId = Optional.ofNullable(requestUrl.getContentTypeId()).orElse(""); + String sigunguCode = Optional.ofNullable(requestUrl.getSigunguCode()).orElse(""); + + int page = Integer.parseInt(pageNo); + int size = Integer.parseInt(numOfRows); + + // 데이터베이스에서 중복 제거된 데이터 가져오기 + List rawEntities = tourAPIRepository.findAllList(); + + // 변수값이 비어있을 때는 오른쪽 filter조건을 건너뜀 + // 변수값이 비어있지 않은 조건들을 모두 만족 시킨 데이터만 필터링함. + List rawItems = rawEntities.stream() + .filter(tourAPI -> + (areaCode.isEmpty() || tourAPI.getAreaCode().equals(areaCode)) && + (contentTypeId.isEmpty() || tourAPI.getContentTypeId().equals(contentTypeId)) && + (sigunguCode.isEmpty() || tourAPI.getSigunguCode().equals(sigunguCode)) && + (keyword.isEmpty() || (tourAPI.getAddr1().contains(keyword) || tourAPI.getTitle().contains(keyword))) + ) + .map(TourAPI::toDto) + .collect(Collectors.toList()); + + List sortedItems = sortItemsByTitle(rawItems); + + TourAPIDto responseDto = buildResponseWithTourAPIDto(sortedItems, page, size); + + return responseDto; + } + + private List sortItemsByTitle(List items) { + Collator koreanCollator = Collator.getInstance(Locale.KOREAN); + + Comparator customComparator = Comparator + //한글, 영문자, 숫자, 기타 순으로 정렬. + .comparing((TourAPIDto.TourResponse.Body.Items.Item item) -> { + String title = item.getTitle(); + if (title.matches("^[가-힣ㄱ-ㅎ].*")) return 0; + if (title.matches("^[a-zA-Z].*")) return 1; + if (title.matches("^[0-9].*")) return 2; + return 3; + }) + // 각 그룹 내 정렬 기준에 맞춰 정렬 + .thenComparing((item1, item2) -> { + String title1 = item1.getTitle(); + String title2 = item2.getTitle(); + if (title1.matches("^[가-힣ㄱ-ㅎ].*") && title2.matches("^[가-힣ㄱ-ㅎ].*")) { + return koreanCollator.compare(title1, title2); + } + if (title1.matches("^[a-zA-Z].*") && title2.matches("^[a-zA-Z].*")) { + return title1.compareToIgnoreCase(title2); + } + return title1.compareTo(title2); + }); + + return items.stream() + .sorted(customComparator) + .collect(Collectors.toList()); + } + + + private TourAPIDto buildResponseWithTourAPIDto(List itemList, int pageNo, int numOfRows) { + // 객체 page별로 반환 + int start = Math.min(pageNo * numOfRows, itemList.size()); + int end = Math.min((pageNo + 1) * numOfRows, itemList.size()); + List pagedItems = itemList.subList(start, end); + + // Items 객체로 변환 + TourAPIDto.TourResponse.Body.Items items = TourAPIDto.TourResponse.Body.Items.builder() + .item(pagedItems) + .build(); + + // Body 객체 생성 + TourAPIDto.TourResponse.Body body = TourAPIDto.TourResponse.Body.builder() + .items(items) + .numOfRows(numOfRows) + .pageNo(pageNo) + .totalCount(itemList.size()) + .build(); + + // Header 객체 생성 + TourAPIDto.TourResponse.Header header = TourAPIDto.TourResponse.Header.builder() + .resultCode("0000") + .resultMsg("OK") + .build(); + + // 최종 응답 객체 생성 + return TourAPIDto.builder() + .response(TourAPIDto.TourResponse.builder() + .header(header) + .body(body) + .build()) + .build(); + } + +} diff --git a/src/main/java/com/minizin/travel/tour/service/TourService.java b/src/main/java/com/minizin/travel/tour/service/TourService.java new file mode 100644 index 0000000..d609a58 --- /dev/null +++ b/src/main/java/com/minizin/travel/tour/service/TourService.java @@ -0,0 +1,350 @@ +package com.minizin.travel.tour.service; + + +import com.minizin.travel.tour.domain.dto.TourAPIDto; +import com.minizin.travel.tour.domain.entity.TourAPI; +import com.minizin.travel.tour.domain.repository.TourAPIRepository; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import okhttp3.OkHttpClient; +import org.springframework.transaction.annotation.Transactional; + +/** + * Class: TourService Project: travel Package: com.minizin.travel.tour.service + *

+ * Description: TourService + * + * @author dong-hoshin + * @date 6/5/24 01:04 Copyright (c) 2024 MiniJin + * @see GitHub Repository + */ +@Service +@RequiredArgsConstructor +public class TourService { + + @Value("${api-tour.serviceKey_De}") + public String serviceKey; + private final OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(6000, TimeUnit.SECONDS) + .readTimeout(6000, TimeUnit.SECONDS) + .writeTimeout(6000, TimeUnit.SECONDS) + .build(); + private final Gson gson = new Gson(); + private final String baseUrl = "https://apis.data.go.kr/B551011/KorService1/"; + private final TourAPIRepository tourAPIRepository; + private final ExecutorService executorService = Executors.newFixedThreadPool(10); + Logger logger = LoggerFactory.getLogger(getClass()); + private static final int MAX_RETRIES = 3; + + public CompletableFuture getTourAPIFromSiteDetailCommon(TourAPIDto.TourRequest requestParam) { + String getCategoryUrl = baseUrl + "detailCommon1"; + // 기존 TourAPI entity(DB)에 detail 정보 추가해서 DB업데이트 하기. + long startTime = System.currentTimeMillis(); + int pageNo = Integer.parseInt(Optional.ofNullable(requestParam.getPageNo()).orElse("0")); + int pageSize = Integer.parseInt(Optional.ofNullable(requestParam.getNumOfRows()).orElse("1000")); + + PageRequest pageRequest = PageRequest.of(pageNo, pageSize); + // overview 정보가 추가 안된 데이터만 중복값 없이 가져오기 + // (pageable 한 이유는 간혹 DB 업데이트가 안되는 data들을 page를 통해 건너뛰기 위함) + Page tourAPIPage = tourAPIRepository.findAll(pageRequest); + List tourAPIs = tourAPIPage.getContent(); + + //가져온 TourAPI 목록의 모든 데이터들을 비동기 방식으로 공공데이터 api에 정보 요청 + List> futures = tourAPIs.stream() + .map(tourAPI -> { + String contentId = tourAPI.getContentId(); + String contentTypeId = tourAPI.getContentTypeId(); + + Map params = Map.ofEntries( + Map.entry("ServiceKey", serviceKey), + Map.entry("MobileOS", "ETC"), + Map.entry("MobileApp", "AppTest"), + Map.entry("_type", "json"), + Map.entry("contentId", contentId), + Map.entry("contentTypeId", contentTypeId), + Map.entry("defaultYN", "Y"), + Map.entry("firstImageYN", "Y"), + Map.entry("areacodeYN", "Y"), + Map.entry("catcodeYN", "Y"), + Map.entry("addrinfoYN", "Y"), + Map.entry("mapinfoYN", "Y"), + Map.entry("overviewYN", "Y"), + Map.entry("pageNo", "0") + ); + // 각 Entity Data별로 공공데이터 Request url에 맞춰 request param을 build 하기. + String url = buildUrlWithParams(getCategoryUrl, params); + // 각 Entity Data별로 공공데이터에 요청한 detail정보 update하기. + return fetchAndUpdateTourAPIData(url, tourAPI); + }) + .collect(Collectors.toList()); + + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> { + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + logger.info("getTourAPIFromSiteDetailCommon executed in " + duration + " ms"); + if (!tourAPIs.isEmpty()) { + // 비동기 방식으로 가져온 모든 업데이트된 정보들을 한번에 저장. + tourAPIRepository.saveAll(tourAPIs); + } + return "getTourAPIFromSiteDetailCommon executed successfully"; + }); + } + public CompletableFuture> getTourAPIFromSiteSearchKeyword(TourAPIDto.TourRequest requestParam) { + //baseUrl에 공공데이터의 request url에 맞춰 카테고리url 넣기 + // + requestParam에 들어간 keyword, pageNo,numOfRows에 맞춰 공공데이터에서 값 가져오기 + String getCategoryUrl = baseUrl + "searchKeyword1"; + String keyword = Optional.ofNullable(requestParam.getKeyword()).orElse("강원"); + String pageNo = Optional.ofNullable(requestParam.getPageNo()).orElse("0"); + String numOfRows = Optional.ofNullable(requestParam.getNumOfRows()).orElse("1000"); + + Map params = Map.of( + "ServiceKey", serviceKey, + "MobileOS", "ETC", + "MobileApp", "AppTest", + "_type", "json", + "keyword", keyword, + "pageNo",pageNo, + "numOfRows", numOfRows + ); + + String url = buildUrlWithParams(getCategoryUrl, params); + + return getListCompletableFuture(url); + } + + public CompletableFuture> getTourAPIFromSiteAreaBasedList(TourAPIDto.TourRequest requestParam) { + String getCategoryUrl = baseUrl + "areaBasedList1"; + long startTime = System.currentTimeMillis(); + String[] areaCodes = {"1","2","3","4","5","6","7","8","31","32","33","34","35","36","37","38","39"}; + String[] contentTypeIds = {"12","14","15","25","28","32","38","39"}; + String pageNo = Optional.ofNullable(requestParam.getPageNo()).orElse("0"); + String numOfRows = Optional.ofNullable(requestParam.getNumOfRows()).orElse("1000"); + /* int[] sigunguCodes = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, 99}; 같은 시군구 코드에 다른지역 표기가 있어 제외*/ + + // stream(contentTypeIds) or Arrays.stream(areaCodes) 로 활용 + List>> futures = Arrays.stream(areaCodes) + .flatMap(areaCode -> Arrays.stream(contentTypeIds) + .map(contentTypeId -> { + Map params = Map.of( + "ServiceKey", serviceKey, + "MobileOS", "ETC", + "MobileApp", "AppTest", + "_type", "json", + "areaCode", areaCode, + "numOfRows", numOfRows, + "pageNo", pageNo, + "contentTypeId", contentTypeId + ); + + String url = buildUrlWithParams(getCategoryUrl, params); + return getListCompletableFuture(url); + }) + ).collect(Collectors.toList()); + + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> { + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + logger.info("getTourAPIFromSiteAreaBasedList executed in " + duration + " ms"); + return futures.stream() + .map(CompletableFuture::join) + .flatMap(List::stream) + .collect(Collectors.toList()); + }); + } + + public CompletableFuture> getTourAPIFromSiteAreaBasedListSingle(TourAPIDto.TourRequest requestParam) { + String getCategoryUrl = baseUrl + "areaBasedList1"; + String pageNo = Optional.ofNullable(requestParam.getPageNo()).orElse("0"); + String numOfRows = Optional.ofNullable(requestParam.getNumOfRows()).orElse("10"); + String areaCode = Optional.ofNullable(requestParam.getAreaCode()).orElse(""); + String contentTypeId = Optional.ofNullable(requestParam.getContentTypeId()).orElse(""); + String sigunguCode = Optional.ofNullable(requestParam.getSigunguCode()).orElse(""); + + Map params = Map.of( + "ServiceKey", serviceKey, + "MobileOS", "ETC", + "MobileApp", "AppTest", + "_type", "json", + "areaCode",areaCode, + "contentTypeId",contentTypeId, + "sigunguCode",sigunguCode, + "pageNo", pageNo, + "numOfRows", numOfRows + ); + + String url = buildUrlWithParams(getCategoryUrl, params); + + return getListCompletableFuture(url); + } + public CompletableFuture> getTourAPIFromSiteAreaCode(TourAPIDto.TourRequest requestParam) { + String getCategoryUrl = baseUrl + "areaCode1"; + String areaCode = Optional.ofNullable(requestParam.getAreaCode()).orElse(""); + // areaCode 가 비어있으면 code(true) areaCode가 있으면 Sigungu(false) + boolean codeOrSigungu = areaCode.isEmpty(); + // areaCode 가 비어있을 때 모든 특별시, 도를 검색 (true) + // areaCode가 있을 때는 해당 areaCode의 sigungu를 검색 (false) + + Map params = Map.of( + "ServiceKey", serviceKey, + "MobileOS", "ETC", + "MobileApp", "AppTest", + "_type", "json", + "numOfRows","100", + "areaCode",areaCode + ); + + String url = buildUrlWithParams(getCategoryUrl, params); + + return getListCompletableFutureForArea(url, areaCode, codeOrSigungu); + } + + @NotNull + private CompletableFuture> getListCompletableFuture(String url) { + // 공공데이터 request url에 맞춰 build된 url에 json형식으로 header 추가 + Request request = new Request.Builder() + .url(url) + .get() + .addHeader("Content-type", "application/json") + .build(); + + //build된 url을 기반으로 공공데이터 비동기 방식으로 response 가져오기 -> Entity 저장 + return CompletableFuture.supplyAsync(() -> { + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Unexpected code " + response); + } + + String responseJson = response.body().string(); + +// logger.info("Response JSON: " + responseJson); // Log the JSON response + + // items이 비어있을 때 Error Msg없이 emptyList 출력 - 이 코드가 없으면 error 발생 + if (responseJson.contains("\"items\": \"\"")) { + return Collections.emptyList(); + } + + TourAPIDto tourAPIDto = gson.fromJson(responseJson, TourAPIDto.class); + + List tourAPIList = tourAPIDto.toEntityList(); + + if (!tourAPIList.isEmpty()) { + tourAPIRepository.saveAll(tourAPIList); + return tourAPIList; + } + } catch (IOException | JsonSyntaxException e) { + if (!e.getMessage().contains("\"items\": \"\",")) { + logger.error("JSON parsing error: " + e.getMessage()); + } + } + return Collections.emptyList(); + }, executorService); + } + + private String buildUrlWithParams(String baseUrl, Map params) { + HttpUrl.Builder urlBuilder = HttpUrl.parse(baseUrl).newBuilder(); + params.forEach((key, value) -> urlBuilder.addQueryParameter(key, value)); + return urlBuilder.build().toString(); + } + + private CompletableFuture fetchAndUpdateTourAPIData(String url, TourAPI existingTourAPI) { + Request request = new Request.Builder() + .url(url) + .get() + .addHeader("Content-type", "application/json") + .build(); + + return CompletableFuture.runAsync(() -> { + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Unexpected code " + response); + } + String responseJson = response.body().string(); + logger.debug("Response JSON: " + responseJson); + + // JSON 파싱 및 데이터 형식 검증 + TourAPIDto tourAPIDto = gson.fromJson(responseJson, TourAPIDto.class); + + TourAPIDto.TourResponse.Body.Items.Item item = tourAPIDto.getResponse() + .getBody() + .getItems() + .getItem() + .get(0); + // 공공데이터에서 가져온 data를 기존 Entity Data(TourAPI)에 업데이트 + existingTourAPI.updateFromDtoAsync(item); + } catch (IOException | JsonSyntaxException e) { + logger.error("Error fetching data for URL: " + url, e); + throw new CompletionException(e); + } + }, executorService); + } + + @NotNull + private CompletableFuture> getListCompletableFutureForArea(String url,String areaCode,boolean codeOrSigungu) { + Request request = new Request.Builder() + .url(url) + .get() + .addHeader("Content-type", "application/json") + .build(); + + return CompletableFuture.supplyAsync(() -> { + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Unexpected code " + response); + } + + String responseJson = response.body().string(); + +// logger.info("Response JSON: " + responseJson); // Log the JSON response + + // items이 비어있을 때 Error Msg없이 emptyList 출력 - 이 코드가 없으면 error 발생 + if (responseJson.contains("\"items\": \"\"")) { + return Collections.emptyList(); + } + + TourAPIDto tourAPIDto = gson.fromJson(responseJson, TourAPIDto.class); + + List tourAPIList = tourAPIDto.toEntityListArea(areaCode,codeOrSigungu); + + if (!tourAPIList.isEmpty()) { + tourAPIRepository.saveAll(tourAPIList); + return tourAPIList; + } + + } catch (IOException | JsonSyntaxException e) { + if (!e.getMessage().contains("\"items\": \"\",")) { + logger.error("JSON parsing error: " + e.getMessage()); + } + } + return Collections.emptyList(); + }, executorService); + } + +} \ No newline at end of file diff --git a/src/main/java/com/minizin/travel/user/auth/LoginFilter.java b/src/main/java/com/minizin/travel/user/auth/LoginFilter.java new file mode 100644 index 0000000..3305fd9 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/auth/LoginFilter.java @@ -0,0 +1,75 @@ +package com.minizin.travel.user.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.minizin.travel.user.domain.dto.ErrorResponse; +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import com.minizin.travel.user.domain.enums.Role; +import com.minizin.travel.user.jwt.TokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import java.io.IOException; + +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final TokenProvider tokenProvider; + + public LoginFilter(AuthenticationManager authenticationManager, TokenProvider tokenProvider) { + // 로그인 path 설정 + this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/auth/login", "POST")); + this.authenticationManager = authenticationManager; + this.tokenProvider = tokenProvider; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + + String username = obtainUsername(request); + String password = obtainPassword(request); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(username, password); + + return authenticationManager.authenticate(authToken); + } + + //로그인 성공시 실행하는 메소드 (여기서 JWT 발급) + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) { + + PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); + + String username = principalDetails.getUsername(); + Role role = principalDetails.getUserEntity().getRole(); + + String token = tokenProvider.createJwt(username, role.toString(), 24 * 60 * 60 * 1000L); + + response.addHeader("Authorization", "Bearer " + token); + } + + //로그인 실패시 실행하는 메소드 + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException { + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.UNAUTHORIZED, "인증에 실패했습니다." + ); + + ObjectMapper objectMapper = new ObjectMapper(); + String result = objectMapper.writeValueAsString(errorResponse); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write(result); + } +} diff --git a/src/main/java/com/minizin/travel/user/controller/AuthController.java b/src/main/java/com/minizin/travel/user/controller/AuthController.java new file mode 100644 index 0000000..ca63278 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/controller/AuthController.java @@ -0,0 +1,43 @@ +package com.minizin.travel.user.controller; + +import com.minizin.travel.user.domain.dto.JoinDto; +import com.minizin.travel.user.service.AuthService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.RestController; + +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @GetMapping("/auth/jwt") + public ResponseEntity getJwt(HttpServletRequest request, HttpServletResponse response) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals("Authorization")) { + String token = cookie.getValue(); + response.setHeader("Authorization", "Bearer " + token); + return ResponseEntity.ok("JWT 헤더로 발급"); + } + } + } + return ResponseEntity.status(401).body("Unauthorized"); + } + + @PostMapping("/auth/join") + public JoinDto.Response join( + @RequestBody @Valid JoinDto.Request request + ) { + return authService.join(request); + } +} diff --git a/src/main/java/com/minizin/travel/user/controller/MailController.java b/src/main/java/com/minizin/travel/user/controller/MailController.java new file mode 100644 index 0000000..8227912 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/controller/MailController.java @@ -0,0 +1,35 @@ +package com.minizin.travel.user.controller; + +import com.minizin.travel.user.domain.dto.SendAuthCodeDto; +import com.minizin.travel.user.domain.dto.VerifyAuthCodeDto; +import com.minizin.travel.user.service.MailService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class MailController { + + private final MailService mailService; + + @PostMapping("/mails/auth-code") + public String sendAuthCode( + @RequestBody @Valid SendAuthCodeDto sendAuthCodeDto + ) { + mailService.sendAuthCode(sendAuthCodeDto); + + return "인증코드가 발송되었습니다."; + } + + @PostMapping("/mails/auth-code/verification") + public String verifyAuthCode( + @RequestBody @Valid VerifyAuthCodeDto verifyAuthCodeDto + ) { + boolean verified = mailService.verifyAuthCode(verifyAuthCodeDto); + + return verified ? "인증 성공" : "인증 실패"; + } +} diff --git a/src/main/java/com/minizin/travel/user/controller/UserController.java b/src/main/java/com/minizin/travel/user/controller/UserController.java new file mode 100644 index 0000000..b875909 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/controller/UserController.java @@ -0,0 +1,68 @@ +package com.minizin.travel.user.controller; + +import com.minizin.travel.user.domain.dto.*; +import com.minizin.travel.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping("/users") + public UserDto.Response getUserInfo( + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + return userService.getUserInfo(principalDetails); + } + + @PostMapping("/users/find-id") + public FindIdDto.Response findId( + @RequestBody @Valid FindIdDto.Request request + ) { + return userService.findId(request); + } + + @PostMapping("/users/find-password") + public String findPassword( + @RequestBody @Valid FindPasswordDto.Request request + ) { + userService.findPassword(request); + return "임시 비밀번호가 메일로 전송되었습니다."; + } + + @PatchMapping("/users/password") + public UpdatePasswordDto.Response updatePassword( + @RequestBody @Valid UpdatePasswordDto.Request request, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + return userService.updatePassword(request, principalDetails); + } + + @PatchMapping("/users/email") + public UpdateEmailDto.Response updateEmail( + @RequestBody @Valid UpdateEmailDto.Request request, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + return userService.updateEmail(request, principalDetails); + } + + @PatchMapping("/users/nickname") + public UpdateNicknameDto.Response updateNickname( + @RequestBody @Valid UpdateNicknameDto.Request request, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + return userService.updateNickname(request, principalDetails); + } + + @DeleteMapping("/users") + public DeleteUserDto.Response deleteUser( + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + return userService.deleteUser(principalDetails); + } +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/DeleteUserDto.java b/src/main/java/com/minizin/travel/user/domain/dto/DeleteUserDto.java new file mode 100644 index 0000000..75e2a97 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/DeleteUserDto.java @@ -0,0 +1,25 @@ +package com.minizin.travel.user.domain.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.user.domain.entity.UserEntity; +import lombok.Builder; +import lombok.Getter; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class DeleteUserDto { + + @Getter + @Builder + public static class Response { + private Boolean success; + private Long userId; + + public static Response fromUserEntity(UserEntity userEntity) { + return Response.builder() + .success(true) + .userId(userEntity.getId()) + .build(); + } + } +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/ErrorResponse.java b/src/main/java/com/minizin/travel/user/domain/dto/ErrorResponse.java new file mode 100644 index 0000000..6e78175 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/ErrorResponse.java @@ -0,0 +1,12 @@ +package com.minizin.travel.user.domain.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public class ErrorResponse { + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/FindIdDto.java b/src/main/java/com/minizin/travel/user/domain/dto/FindIdDto.java new file mode 100644 index 0000000..e10acff --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/FindIdDto.java @@ -0,0 +1,26 @@ +package com.minizin.travel.user.domain.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +public class FindIdDto { + @Getter + @Setter + public static class Request { + @NotBlank + @Email + // 64 (local) + 1 (at) + 255 (domain) = 320 + @Size(max = 320, message = "Email must be less than 320 characters") + String email; + } + + @Getter + @Builder + public static class Response { + String username; + } +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/FindPasswordDto.java b/src/main/java/com/minizin/travel/user/domain/dto/FindPasswordDto.java new file mode 100644 index 0000000..98637b1 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/FindPasswordDto.java @@ -0,0 +1,25 @@ +package com.minizin.travel.user.domain.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +public class FindPasswordDto { + @Getter + @Setter + public static class Request { + + @NotBlank + @Size(min = 6, max = 20, message = "Username must be between 6 and 20 characters") + private String username; + + @NotBlank + @Email + // 64 (local) + 1 (at) + 255 (domain) = 320 + @Size(max = 320, message = "Email must be less than 320 characters") + String email; + } + +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/JoinDto.java b/src/main/java/com/minizin/travel/user/domain/dto/JoinDto.java new file mode 100644 index 0000000..ed8e079 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/JoinDto.java @@ -0,0 +1,73 @@ +package com.minizin.travel.user.domain.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.LoginType; +import com.minizin.travel.user.domain.enums.Role; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class JoinDto { + @Getter + @Setter + public static class Request { + @NotBlank + @Size(min = 6, max = 20, message = "Username must be between 6 and 20 characters") + private String username; + + @NotBlank + @Size(min = 8, max = 20, message = "Password must be between 8 and 20 characters") + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!-~]).*$", + message = "Password must contain at least one uppercase letter," + + " one lowercase letter, one number, and one special character") + private String password; + + private String nickname; + + @NotBlank + @Email + // 64 (local) + 1 (at) + 255 (domain) = 320 + @Size(max = 320, message = "Email must be less than 320 characters") + private String email; + + } + + @Getter + @Builder + public static class Response { + private Boolean success; + private Long userId; + private String username; + private String password; + private String nickname; + private String email; + private Role role; + private LoginType loginType; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static Response fromUserEntity(UserEntity userEntity) { + return Response.builder() + .success(true) + .userId(userEntity.getId()) + .username(userEntity.getUsername()) + .password(userEntity.getPassword()) + .nickname(userEntity.getNickname()) + .email(userEntity.getEmail()) + .role(userEntity.getRole()) + .loginType(userEntity.getLoginType()) + .createdAt(userEntity.getCreatedAt()) + .updatedAt(userEntity.getUpdatedAt()) + .build(); + } + } +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/KakaoResponse.java b/src/main/java/com/minizin/travel/user/domain/dto/KakaoResponse.java new file mode 100644 index 0000000..3af2517 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/KakaoResponse.java @@ -0,0 +1,36 @@ +package com.minizin.travel.user.domain.dto; + +import java.util.Map; + +public class KakaoResponse { + + private final Map attributes; + private final Map kakaoAccount; + private final Map profile; + + public KakaoResponse(Map attributes) { + this.attributes = attributes; + this.kakaoAccount = (Map) attributes.get("kakao_account"); + this.profile = (Map) kakaoAccount.get("profile"); + } + + // 제공자 (Ex. naver, google, ...) + public String getProvider() { + return "kakao"; + } + + // 제공자에서 발급해주는 아이디(번호) + public String getProviderId() { + return this.attributes.get("id").toString(); + } + + // 이메일 + public String getEmail() { + return this.kakaoAccount.get("email").toString(); + } + + // 프로필로 설정된 이름 + public String getNickname() { + return this.profile.get("nickname").toString(); + } +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/PrincipalDetails.java b/src/main/java/com/minizin/travel/user/domain/dto/PrincipalDetails.java new file mode 100644 index 0000000..08a5727 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/PrincipalDetails.java @@ -0,0 +1,67 @@ +package com.minizin.travel.user.domain.dto; + +import com.minizin.travel.user.domain.entity.UserEntity; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +@Getter +@RequiredArgsConstructor +public class PrincipalDetails implements UserDetails, OAuth2User { + + private final UserEntity userEntity; + + @Override + public Map getAttributes() { + return null; + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + collection.add((GrantedAuthority) () -> userEntity.getRole().toString()); + return collection; + } + + @Override + public String getName() { + return userEntity.getUsername(); + } + + @Override + public String getPassword() { + return userEntity.getPassword(); + } + + @Override + public String getUsername() { + return userEntity.getUsername(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/SendAuthCodeDto.java b/src/main/java/com/minizin/travel/user/domain/dto/SendAuthCodeDto.java new file mode 100644 index 0000000..bdf18d7 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/SendAuthCodeDto.java @@ -0,0 +1,17 @@ +package com.minizin.travel.user.domain.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SendAuthCodeDto { + @NotBlank + @Email + // 64 (local) + 1 (at) + 255 (domain) = 320 + @Size(max = 320, message = "Email must be less than 320 characters") + private String email; +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/UpdateEmailDto.java b/src/main/java/com/minizin/travel/user/domain/dto/UpdateEmailDto.java new file mode 100644 index 0000000..27aa7d3 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/UpdateEmailDto.java @@ -0,0 +1,42 @@ +package com.minizin.travel.user.domain.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.user.domain.entity.UserEntity; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class UpdateEmailDto { + @Getter + @Setter + public static class Request { + + @NotBlank + @Email + // 64 (local) + 1 (at) + 255 (domain) = 320 + @Size(max = 320, message = "Email must be less than 320 characters") + private String email; + + } + + @Getter + @Builder + public static class Response { + private Boolean success; + private Long userId; + private String changedEmail; + + public static Response fromUserEntity(UserEntity userEntity) { + return Response.builder() + .success(true) + .userId(userEntity.getId()) + .changedEmail(userEntity.getEmail()) + .build(); + } + } +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/UpdateNicknameDto.java b/src/main/java/com/minizin/travel/user/domain/dto/UpdateNicknameDto.java new file mode 100644 index 0000000..e907410 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/UpdateNicknameDto.java @@ -0,0 +1,35 @@ +package com.minizin.travel.user.domain.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.user.domain.entity.UserEntity; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class UpdateNicknameDto { + @Getter + @Setter + public static class Request { + + private String nickname; + + } + + @Getter + @Builder + public static class Response { + private Boolean success; + private Long userId; + private String changedNickname; + + public static Response fromUserEntity(UserEntity userEntity) { + return Response.builder() + .success(true) + .userId(userEntity.getId()) + .changedNickname(userEntity.getNickname()) + .build(); + } + } +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/UpdatePasswordDto.java b/src/main/java/com/minizin/travel/user/domain/dto/UpdatePasswordDto.java new file mode 100644 index 0000000..63e46a9 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/UpdatePasswordDto.java @@ -0,0 +1,50 @@ +package com.minizin.travel.user.domain.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.minizin.travel.user.domain.entity.UserEntity; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class UpdatePasswordDto { + @Getter + @Setter + public static class Request { + + @NotBlank + @Size(min = 8, max = 20, message = "Password must be between 8 and 20 characters") + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!-~]).*$", + message = "Password must contain at least one uppercase letter," + + " one lowercase letter, one number, and one special character") + private String originalPassword; + + @NotBlank + @Size(min = 8, max = 20, message = "Password must be between 8 and 20 characters") + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!-~]).*$", + message = "Password must contain at least one uppercase letter," + + " one lowercase letter, one number, and one special character") + private String changePassword; + + } + + @Getter + @Builder + public static class Response { + private Boolean success; + private Long userId; + private String changedPassword; + + public static Response fromUserEntity(UserEntity userEntity) { + return Response.builder() + .success(true) + .userId(userEntity.getId()) + .changedPassword(userEntity.getPassword()) + .build(); + } + } +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/UserDto.java b/src/main/java/com/minizin/travel/user/domain/dto/UserDto.java new file mode 100644 index 0000000..e028be7 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/UserDto.java @@ -0,0 +1,25 @@ +package com.minizin.travel.user.domain.dto; + +import com.minizin.travel.user.domain.entity.UserEntity; +import lombok.Builder; +import lombok.Getter; + +public class UserDto { + @Getter + @Builder + public static class Response { + private Long userId; + private String username; + private String email; + private String nickname; + + public static Response fromUserEntity(UserEntity userEntity) { + return Response.builder() + .userId(userEntity.getId()) + .username(userEntity.getUsername()) + .email(userEntity.getEmail()) + .nickname(userEntity.getNickname()) + .build(); + } + } +} diff --git a/src/main/java/com/minizin/travel/user/domain/dto/VerifyAuthCodeDto.java b/src/main/java/com/minizin/travel/user/domain/dto/VerifyAuthCodeDto.java new file mode 100644 index 0000000..cba172f --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/dto/VerifyAuthCodeDto.java @@ -0,0 +1,24 @@ +package com.minizin.travel.user.domain.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class VerifyAuthCodeDto { + + @NotBlank + @Email + // 64 (local) + 1 (at) + 255 (domain) = 320 + @Size(max = 320, message = "Email must be less than 320 characters") + private String email; + + @NotBlank + @Pattern(regexp = "^\\d{6}$", message = "AuthCode must be 6 digits") + private String authCode; + +} diff --git a/src/main/java/com/minizin/travel/user/domain/entity/UserEntity.java b/src/main/java/com/minizin/travel/user/domain/entity/UserEntity.java new file mode 100644 index 0000000..aa28783 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/entity/UserEntity.java @@ -0,0 +1,49 @@ +package com.minizin.travel.user.domain.entity; + +import com.minizin.travel.user.domain.enums.LoginType; +import com.minizin.travel.user.domain.enums.Role; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String username; + + @Setter + private String password; + + @Setter + private String email; + + @Setter + private String nickname; + + @Enumerated(EnumType.STRING) + private Role role; + + @Enumerated(EnumType.STRING) + private LoginType loginType; + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/com/minizin/travel/user/domain/enums/LoginType.java b/src/main/java/com/minizin/travel/user/domain/enums/LoginType.java new file mode 100644 index 0000000..db19748 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/enums/LoginType.java @@ -0,0 +1,6 @@ +package com.minizin.travel.user.domain.enums; + +public enum LoginType { + LOCAL, + KAKAO +} diff --git a/src/main/java/com/minizin/travel/user/domain/enums/MailErrorCode.java b/src/main/java/com/minizin/travel/user/domain/enums/MailErrorCode.java new file mode 100644 index 0000000..b572ab4 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/enums/MailErrorCode.java @@ -0,0 +1,17 @@ +package com.minizin.travel.user.domain.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MailErrorCode { + + AUTH_CODE_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "인증코드 발송에 실패했습니다."), + TEMPORARY_PASSWORD_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "임시 비밀번호 발송에 실패했습니다."), + ; + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/minizin/travel/user/domain/enums/Role.java b/src/main/java/com/minizin/travel/user/domain/enums/Role.java new file mode 100644 index 0000000..c332231 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/enums/Role.java @@ -0,0 +1,5 @@ +package com.minizin.travel.user.domain.enums; + +public enum Role { + ROLE_USER, +} diff --git a/src/main/java/com/minizin/travel/user/domain/enums/UserErrorCode.java b/src/main/java/com/minizin/travel/user/domain/enums/UserErrorCode.java new file mode 100644 index 0000000..e5409ff --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/enums/UserErrorCode.java @@ -0,0 +1,20 @@ +package com.minizin.travel.user.domain.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode { + + USER_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "이미 존재하는 사용자입니다."), + USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 사용자입니다."), + EMAIL_UN_REGISTERED(HttpStatus.BAD_REQUEST, "등록되지 않은 이메일입니다."), + USER_EMAIL_UN_MATCHED(HttpStatus.BAD_REQUEST, "사용자에게 등록된 이메일이 아닙니다."), + PASSWORD_UN_MATCHED(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), + ; + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/minizin/travel/user/domain/exception/CustomMailException.java b/src/main/java/com/minizin/travel/user/domain/exception/CustomMailException.java new file mode 100644 index 0000000..a793bb8 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/exception/CustomMailException.java @@ -0,0 +1,11 @@ +package com.minizin.travel.user.domain.exception; + +import com.minizin.travel.user.domain.enums.MailErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomMailException extends RuntimeException { + private final MailErrorCode mailErrorCode; +} diff --git a/src/main/java/com/minizin/travel/user/domain/exception/CustomUserException.java b/src/main/java/com/minizin/travel/user/domain/exception/CustomUserException.java new file mode 100644 index 0000000..a769c8f --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/exception/CustomUserException.java @@ -0,0 +1,11 @@ +package com.minizin.travel.user.domain.exception; + +import com.minizin.travel.user.domain.enums.UserErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomUserException extends RuntimeException { + private final UserErrorCode userErrorCode; +} diff --git a/src/main/java/com/minizin/travel/user/domain/repository/UserRepository.java b/src/main/java/com/minizin/travel/user/domain/repository/UserRepository.java new file mode 100644 index 0000000..ae4a63c --- /dev/null +++ b/src/main/java/com/minizin/travel/user/domain/repository/UserRepository.java @@ -0,0 +1,19 @@ +package com.minizin.travel.user.domain.repository; + +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.LoginType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + Boolean existsByUsername(String username); + + Optional findByEmailAndLoginType(String email, LoginType loginType); + +} diff --git a/src/main/java/com/minizin/travel/user/jwt/JwtAuthenticationFilter.java b/src/main/java/com/minizin/travel/user/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..47fdadd --- /dev/null +++ b/src/main/java/com/minizin/travel/user/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,67 @@ +package com.minizin.travel.user.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String TOKEN_HEADER = "Authorization"; + private static final String TOKEN_PREFIX = "Bearer "; + private final TokenProvider tokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // 특정 경로 무시 + String requestUri = request.getRequestURI(); + if (requestUri.matches("^\\/login(?:\\/.*)?$") || + requestUri.matches("^\\/oauth2(?:\\/.*)?$") || + requestUri.matches("^\\/auth(?:\\/.*)?$") || + requestUri.matches("^\\/mails\\/auth-code(?:\\/.*)?$") || + requestUri.matches("^\\/users\\/find-id(?:\\/.*)?$") || + requestUri.matches("^\\/users\\/find-password(?:\\/.*)?$") || + requestUri.matches("^\\/plans\\/others(?:\\/.*)?$") || + requestUri.matches("^\\/plans\\/popular\\/week(?:\\/.*)?$") + ) { + + filterChain.doFilter(request, response); + return; + } + + String token = resolveTokenFromRequest(request); + + // 토큰이 유효하다면 + if (StringUtils.hasText(token) && !tokenProvider.isExpired(token)) { + + // 스프링 시큐리티 인증 토큰 생성 + Authentication authToken = tokenProvider.getAuthentication(token); + + // 세션에 사용자 등록 + SecurityContextHolder.getContext().setAuthentication(authToken); + } + + filterChain.doFilter(request, response); + } + + private String resolveTokenFromRequest(HttpServletRequest request) { + String token = request.getHeader(TOKEN_HEADER); + + if (!ObjectUtils.isEmpty(token) && token.startsWith(TOKEN_PREFIX)) { + return token.substring(TOKEN_PREFIX.length()); + } + + return null; + } +} diff --git a/src/main/java/com/minizin/travel/user/jwt/JwtExceptionFilter.java b/src/main/java/com/minizin/travel/user/jwt/JwtExceptionFilter.java new file mode 100644 index 0000000..d1247fa --- /dev/null +++ b/src/main/java/com/minizin/travel/user/jwt/JwtExceptionFilter.java @@ -0,0 +1,41 @@ +package com.minizin.travel.user.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.minizin.travel.user.domain.dto.ErrorResponse; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtExceptionFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (JwtException e) { + this.setErrorResponse(response, e); + } + } + + private void setErrorResponse(HttpServletResponse response, Throwable e) throws IOException { + response.setCharacterEncoding("utf-8"); + response.setContentType("application/json"); + + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.UNAUTHORIZED, e.getMessage() + ); + + ObjectMapper objectMapper = new ObjectMapper(); + String result = objectMapper.writeValueAsString(errorResponse); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write(result); + } +} diff --git a/src/main/java/com/minizin/travel/user/jwt/TokenProvider.java b/src/main/java/com/minizin/travel/user/jwt/TokenProvider.java new file mode 100644 index 0000000..5648ff3 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/jwt/TokenProvider.java @@ -0,0 +1,61 @@ +package com.minizin.travel.user.jwt; + +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.Role; +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class TokenProvider { + + private final SecretKey secretKey; + + public TokenProvider(@Value("${spring.jwt.secret}") String secret) { + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUsername(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class); + } + public String getRole(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + + public Boolean isExpired(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + } + + public String createJwt(String username, String role, Long expiredMs) { + return Jwts.builder() + .claim("username", username) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + public Authentication getAuthentication(String token) { + UserEntity userEntity = UserEntity.builder() + .username(this.getUsername(token)) + .role(Role.valueOf(this.getRole(token))) + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + return new UsernamePasswordAuthenticationToken(principalDetails, + "", principalDetails.getAuthorities()); + } +} diff --git a/src/main/java/com/minizin/travel/user/oauth2/CustomSuccessHandler.java b/src/main/java/com/minizin/travel/user/oauth2/CustomSuccessHandler.java new file mode 100644 index 0000000..4973b01 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/oauth2/CustomSuccessHandler.java @@ -0,0 +1,54 @@ +package com.minizin.travel.user.oauth2; + +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import com.minizin.travel.user.domain.enums.Role; +import com.minizin.travel.user.jwt.TokenProvider; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final TokenProvider tokenProvider; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + + //OAuth2User + PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); + + String username = principalDetails.getUsername(); + Role role = principalDetails.getUserEntity().getRole(); + + String token = tokenProvider.createJwt(username, role.toString(), 24 * 60 * 60 * 1000L); + + // 응답에 쿠키로 jwt 발급 + response.addHeader("Set-Cookie", createCookie("Authorization", token).toString()); + + // 프론트 측 특정 uri로 리다이렉트 + response.sendRedirect("https://fe-two-blond.vercel.app/kakao-redirect"); + } + + private ResponseCookie createCookie(String key, String value) { + + ResponseCookie cookie = ResponseCookie.from(key, value) + .maxAge(60 * 60 * 60) + .secure(true) + .path("/") + .httpOnly(true) + .sameSite("None") + .build(); + + return cookie; + } + +} diff --git a/src/main/java/com/minizin/travel/user/service/AuthService.java b/src/main/java/com/minizin/travel/user/service/AuthService.java new file mode 100644 index 0000000..33b942e --- /dev/null +++ b/src/main/java/com/minizin/travel/user/service/AuthService.java @@ -0,0 +1,39 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.JoinDto; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.LoginType; +import com.minizin.travel.user.domain.enums.Role; +import com.minizin.travel.user.domain.enums.UserErrorCode; +import com.minizin.travel.user.domain.exception.CustomUserException; +import com.minizin.travel.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + public JoinDto.Response join(JoinDto.Request request) { + if (userRepository.existsByUsername(request.getUsername())) { + throw new CustomUserException(UserErrorCode.USER_ALREADY_EXIST); + } + + UserEntity saved = userRepository.save(UserEntity.builder() + .username(request.getUsername()) + .password( + bCryptPasswordEncoder.encode(request.getPassword()) + ) + .nickname(request.getNickname()) + .email(request.getEmail()) + .role(Role.ROLE_USER) + .loginType(LoginType.LOCAL) + .build()); + + return JoinDto.Response.fromUserEntity(saved); + } +} diff --git a/src/main/java/com/minizin/travel/user/service/CustomOAuth2UserService.java b/src/main/java/com/minizin/travel/user/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..09cbcb8 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/service/CustomOAuth2UserService.java @@ -0,0 +1,60 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.KakaoResponse; +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.LoginType; +import com.minizin.travel.user.domain.enums.Role; +import com.minizin.travel.user.domain.repository.UserRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final UserRepository userRepository; + private final DefaultOAuth2UserService delegate; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + KakaoResponse kakaoResponse = null; + if ("kakao".equals(registrationId)) { + kakaoResponse = new KakaoResponse(oAuth2User.getAttributes()); + } else { + return null; + } + + //리소스 서버에서 발급 받은 정보로 사용자를 특정할 아이디 값을 만듬 + String username = kakaoResponse.getProvider() + " " + kakaoResponse.getProviderId(); + + UserEntity userEntity = null; + Optional existUser = userRepository.findByUsername(username); + if (existUser.isEmpty()) { + userEntity = userRepository.save(UserEntity.builder() + .username(username) + .email(kakaoResponse.getEmail()) + .nickname(kakaoResponse.getNickname()) + .role(Role.ROLE_USER) + .loginType(LoginType.KAKAO) + .build()); + } else { + userEntity = existUser.get(); + } + + return new PrincipalDetails(userEntity); + } +} diff --git a/src/main/java/com/minizin/travel/user/service/CustomUserDetailsService.java b/src/main/java/com/minizin/travel/user/service/CustomUserDetailsService.java new file mode 100644 index 0000000..f15bb21 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/service/CustomUserDetailsService.java @@ -0,0 +1,25 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + UserEntity userEntity = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 사용자입니다.")); + + return new PrincipalDetails(userEntity); + } +} diff --git a/src/main/java/com/minizin/travel/user/service/MailService.java b/src/main/java/com/minizin/travel/user/service/MailService.java new file mode 100644 index 0000000..41505d5 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/service/MailService.java @@ -0,0 +1,152 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.FindPasswordDto; +import com.minizin.travel.user.domain.dto.SendAuthCodeDto; +import com.minizin.travel.user.domain.dto.VerifyAuthCodeDto; +import com.minizin.travel.user.domain.enums.MailErrorCode; +import com.minizin.travel.user.domain.exception.CustomMailException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import java.security.SecureRandom; +import java.util.Random; + +@Service +@RequiredArgsConstructor +public class MailService { + + private final JavaMailSender javaMailSender; + private final RedisService redisService; + + @Value("${spring.mail.username}") + private String from; + + public Boolean verifyAuthCode(VerifyAuthCodeDto verifyAuthCodeDto) { + String email = verifyAuthCodeDto.getEmail(); + String authCode = verifyAuthCodeDto.getAuthCode(); + + String data = redisService.getData(email); + if (data == null) { + return false; + } + + return authCode.equals(data); + } + + public void sendAuthCode(SendAuthCodeDto sendAuthCodeDto) { + try { + javaMailSender.send(this.createEmailForm(sendAuthCodeDto.getEmail())); + } catch (Exception e) { + throw new CustomMailException(MailErrorCode.AUTH_CODE_SEND_FAILED); + } + + } + + public String sendTemporaryPassword(FindPasswordDto.Request request) { + String password = this.createPassword(); + try { + javaMailSender.send(this.createPasswordForm(request.getEmail(), password)); + } catch (Exception e) { + throw new CustomMailException(MailErrorCode.TEMPORARY_PASSWORD_SEND_FAILED); + } + + return password; + } + + private MimeMessage createPasswordForm(String email, String password) throws MessagingException { + + MimeMessage message = javaMailSender.createMimeMessage(); + message.addRecipients(MimeMessage.RecipientType.TO, email); + message.setSubject("안녕하세요. 임시 비밀번호입니다."); + message.setFrom(from); + message.setText(setPasswordContext(password), "utf-8", "html"); + + return message; + } + + private MimeMessage createEmailForm(String email) throws MessagingException { + + String authCode = createCode(); + + // redis 에 해당 인증코드 저장 (만료 시간도 함께 설정) + redisService.saveWithExpirationTime(email, authCode, 60 * 5L); + + MimeMessage message = javaMailSender.createMimeMessage(); + message.addRecipients(MimeMessage.RecipientType.TO, email); + message.setSubject("안녕하세요. 인증번호입니다."); + message.setFrom(from); + message.setText(setContext(authCode), "utf-8", "html"); + + return message; + } + + private String createPassword() { + String upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + String lower = "abcdefghijklmnopqrstuvwxyz"; + String digits = "0123456789"; + String special = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; + + SecureRandom random = new SecureRandom(); + StringBuilder password = new StringBuilder(8); + + password.append(lower.charAt(random.nextInt(lower.length()))); + password.append(upper.charAt(random.nextInt(upper.length()))); + password.append(digits.charAt(random.nextInt(digits.length()))); + password.append(special.charAt(random.nextInt(special.length()))); + + String allChars = upper + lower + digits + special; + for (int i = 4; i < 8; i++) { + password.append(allChars.charAt(random.nextInt(allChars.length()))); + } + + return password.toString(); + } + + // 인증코드 생성 + private String createCode() { + return Integer.toString(100000 + new Random().nextInt(900000)); + } + + // 이메일 내용 초기화 + private String setContext(String authCode) { + Context context = new Context(); + SpringTemplateEngine templateEngine = new SpringTemplateEngine(); + ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); + + context.setVariable("code", authCode); + + templateResolver.setPrefix("templates/"); + templateResolver.setSuffix(".html"); + templateResolver.setTemplateMode(TemplateMode.HTML); + templateResolver.setCacheable(false); + + templateEngine.setTemplateResolver(templateResolver); + + return templateEngine.process("mailForm", context); + } + + private String setPasswordContext(String password) { + Context context = new Context(); + SpringTemplateEngine templateEngine = new SpringTemplateEngine(); + ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver(); + + context.setVariable("password", password); + + templateResolver.setPrefix("templates/"); + templateResolver.setSuffix(".html"); + templateResolver.setTemplateMode(TemplateMode.HTML); + templateResolver.setCacheable(false); + + templateEngine.setTemplateResolver(templateResolver); + + return templateEngine.process("passwordMailForm", context); + } +} diff --git a/src/main/java/com/minizin/travel/user/service/RedisService.java b/src/main/java/com/minizin/travel/user/service/RedisService.java new file mode 100644 index 0000000..9880e29 --- /dev/null +++ b/src/main/java/com/minizin/travel/user/service/RedisService.java @@ -0,0 +1,23 @@ +package com.minizin.travel.user.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class RedisService { + + private final StringRedisTemplate stringRedisTemplate; + + public void saveWithExpirationTime(String key, String value, long duration) { + stringRedisTemplate.opsForValue().set(key, value, Duration.ofSeconds(duration)); + } + + public String getData(String key) { + return stringRedisTemplate.opsForValue().get(key); + } + +} diff --git a/src/main/java/com/minizin/travel/user/service/UserService.java b/src/main/java/com/minizin/travel/user/service/UserService.java new file mode 100644 index 0000000..1bcc33a --- /dev/null +++ b/src/main/java/com/minizin/travel/user/service/UserService.java @@ -0,0 +1,96 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.*; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.LoginType; +import com.minizin.travel.user.domain.enums.UserErrorCode; +import com.minizin.travel.user.domain.exception.CustomUserException; +import com.minizin.travel.user.domain.repository.UserRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final MailService mailService; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + public UserDto.Response getUserInfo(PrincipalDetails principalDetails) { + UserEntity userEntity = userRepository.findByUsername(principalDetails.getUsername()) + .orElseThrow(() -> new CustomUserException(UserErrorCode.USER_NOT_FOUND)); + + return UserDto.Response.fromUserEntity(userEntity); + } + + public FindIdDto.Response findId(FindIdDto.Request request) { + UserEntity userEntity = userRepository.findByEmailAndLoginType(request.getEmail(), LoginType.LOCAL) + .orElseThrow(() -> new CustomUserException(UserErrorCode.EMAIL_UN_REGISTERED)); + + return FindIdDto.Response.builder().username(userEntity.getUsername()).build(); + } + + @Transactional + public void findPassword(FindPasswordDto.Request request) { + UserEntity userEntity = userRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new CustomUserException(UserErrorCode.USER_NOT_FOUND)); + if (!Objects.equals(userEntity.getEmail(), request.getEmail())) { + throw new CustomUserException(UserErrorCode.USER_EMAIL_UN_MATCHED); + } + + String password = mailService.sendTemporaryPassword(request); + userEntity.setPassword(bCryptPasswordEncoder.encode(password)); + } + + @Transactional + public UpdatePasswordDto.Response updatePassword( + UpdatePasswordDto.Request request, PrincipalDetails principalDetails) { + UserEntity userEntity = userRepository.findByUsername(principalDetails.getUsername()) + .orElseThrow(() -> new CustomUserException(UserErrorCode.USER_NOT_FOUND)); + if (!bCryptPasswordEncoder.matches(request.getOriginalPassword(), userEntity.getPassword())) { + throw new CustomUserException(UserErrorCode.PASSWORD_UN_MATCHED); + } + + userEntity.setPassword(bCryptPasswordEncoder.encode(request.getChangePassword())); + + return UpdatePasswordDto.Response.fromUserEntity(userEntity); + } + + @Transactional + public UpdateEmailDto.Response updateEmail( + UpdateEmailDto.Request request, PrincipalDetails principalDetails + ) { + UserEntity userEntity = userRepository.findByUsername(principalDetails.getUsername()) + .orElseThrow(() -> new CustomUserException(UserErrorCode.USER_NOT_FOUND)); + + userEntity.setEmail(request.getEmail()); + + return UpdateEmailDto.Response.fromUserEntity(userEntity); + } + + @Transactional + public UpdateNicknameDto.Response updateNickname( + UpdateNicknameDto.Request request, PrincipalDetails principalDetails + ) { + UserEntity userEntity = userRepository.findByUsername(principalDetails.getUsername()) + .orElseThrow(() -> new CustomUserException(UserErrorCode.USER_NOT_FOUND)); + + userEntity.setNickname(request.getNickname()); + + return UpdateNicknameDto.Response.fromUserEntity(userEntity); + } + + public DeleteUserDto.Response deleteUser(PrincipalDetails principalDetails) { + UserEntity userEntity = userRepository.findByUsername(principalDetails.getUsername()) + .orElseThrow(() -> new CustomUserException(UserErrorCode.USER_NOT_FOUND)); + + userRepository.delete(userEntity); + + return DeleteUserDto.Response.fromUserEntity(userEntity); + } +} diff --git a/src/main/resources/application-oauth.yml b/src/main/resources/application-oauth.yml new file mode 100644 index 0000000..7328351 --- /dev/null +++ b/src/main/resources/application-oauth.yml @@ -0,0 +1,21 @@ +spring: + security: + oauth2: + client: + registration: + kakao: + client-name: kakao + client-id: 3d90bb75347bd4b5962a422915dcad90 + client-secret: yaE467VCfY8VKFrmiqOBWD6YCVG6BGhZ + client-authentication-method: client_secret_post + redirect-uri: https://lyckabc.synology.me:23080/login/oauth2/code/kakao + authorization-grant-type: authorization_code + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml deleted file mode 100644 index ea322ea..0000000 --- a/src/main/resources/application.yaml +++ /dev/null @@ -1,37 +0,0 @@ -spring: - mvc: - pathmatch: - matching-strategy: ant_path_matcher - datasource: - h2: - url: jdbc:h2:mem:test - username: sa - password: - driverClassName: org.h2.Driver - mariadb: - url: jdbc:mariadb://localhost:3306/travel - username: root - password: 1q!1q! - driverClassName: org.mariadb.jdbc.Driver - - jpa: - hibernate: - ddl-auto: update - properties: - hibernate: - format_sql: true - database-platform: org.hibernate.dialect.MariaDBDialect - - logging: - level: - root: INFO - file: - name: logs/myapp.log - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %class{50}.%M:%line - %msg%n" - file: "%date{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50}:%line - %msg%n" - org: - springframework : DEBUG - hibernate: - SQL: DEBUG - type: descriptor.sql.BasicBinder=TRACE \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..1807ec3 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,64 @@ +spring: + profiles: + include: oauth + + datasource: + url: jdbc:mariadb://localhost:3306/travel + username: root + password: 1q!1q! + driver-class-name: org.mariadb.jdbc.Driver + + jpa: + hibernate: + naming: + physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.MariaDBDialect + format_sql: true + + + data: + redis: + host: localhost + port: 6379 + + mail: + host: smtp.naver.com + port: 465 + username: ENC(VjnM+wDg8lmtHcsk8QE9ECyZJYbH6R+5t5WyYDnFnWYNcb6Mw3yH0ksvC/MUed2laFNHUTlkIP/JHcHfvx8H0Q==) + password: ENC(CKqFnC3DcwpqaKq0jBK/xhV/gKlKk/iyKVGvnya9ci/cPhvn3XMDvXHcGBNKYYbl) + + jwt: + secret: vmfhaltmskdlstkfkdgodyroqkfwkdbalroqkfwkdbalaaaaaaaaaaaaaaaabbbbb + + logging: + level: + com.minizin.travel.config: INFO + root: INFO + file: + name: logs/myapp.log + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %class{50}.%M:%line - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50}:%line - %msg%n" + org: + springframework: + security: INFO + web: INFO + beans.factory: INFO + hibernate: + SQL: DEBUG + type: + descriptor.sql.BasicBinder: INFO +springdoc: + swagger-ui: + path: /swagger-ui.html + api-docs: + path: /v3/api-docs +server: + forward-headers-strategy: framework + +api-tour: + serviceKey_De: K9eWaIkQyqUUDZPWH+jI2Br1awNS1WaksaOZ6EiUbWTEzpVBeXnhTuPjni4n6auCsAUANYZ5o3Q89TF0sFU4bA== + serviceKey_En: K9eWaIkQyqUUDZPWH%2BjI2Br1awNS1WaksaOZ6EiUbWTEzpVBeXnhTuPjni4n6auCsAUANYZ5o3Q89TF0sFU4bA%3D%3D diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..ad6ca45 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,33 @@ + + + + + ${LOG_FILE} + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + logs/myapp-%d{yyyy-MM-dd}.%i.log + + 10MB + + 30 + + + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/mailForm.html b/src/main/resources/templates/mailForm.html new file mode 100644 index 0000000..5fe0345 --- /dev/null +++ b/src/main/resources/templates/mailForm.html @@ -0,0 +1,15 @@ + + + +

+
+

인증 코드 메일입니다.

+
+

아래 코드를 사이트에 입력해주세요.

+
+
+

+
+
+
+ \ No newline at end of file diff --git a/src/main/resources/templates/passwordMailForm.html b/src/main/resources/templates/passwordMailForm.html new file mode 100644 index 0000000..f97c3cf --- /dev/null +++ b/src/main/resources/templates/passwordMailForm.html @@ -0,0 +1,14 @@ + + + +
+
+

임시 비밀번호입니다.

+
+
+
+

+
+
+
+ \ No newline at end of file diff --git a/src/test/java/com/minizin/travel/config/JasyptConfigTest.java b/src/test/java/com/minizin/travel/config/JasyptConfigTest.java new file mode 100644 index 0000000..5037e57 --- /dev/null +++ b/src/test/java/com/minizin/travel/config/JasyptConfigTest.java @@ -0,0 +1,29 @@ +package com.minizin.travel.config; + +import org.jasypt.encryption.pbe.StandardPBEStringEncryptor; +import org.jasypt.iv.RandomIvGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class JasyptConfigTest { + + @Test + @DisplayName("암호화 결과 출력") + void stringEncryptor() { + String text = "text"; + + System.out.println("encrypted: " + jasyptEncoding(text)); + } + + private String jasyptEncoding(String value) { + + String key = "my_jasypt_key"; + + StandardPBEStringEncryptor pbeEnc = new StandardPBEStringEncryptor(); + pbeEnc.setAlgorithm("PBEWITHHMACSHA512ANDAES_256"); + pbeEnc.setPassword(key); + pbeEnc.setIvGenerator(new RandomIvGenerator()); + + return pbeEnc.encrypt(value); + } +} \ No newline at end of file diff --git a/src/test/java/com/minizin/travel/plan/CreatePlanTest.java b/src/test/java/com/minizin/travel/plan/CreatePlanTest.java deleted file mode 100644 index 910bc18..0000000 --- a/src/test/java/com/minizin/travel/plan/CreatePlanTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.minizin.travel.plan; - -import com.minizin.travel.plan.dto.PlanBudgetDto; -import com.minizin.travel.plan.dto.PlanDto; -import com.minizin.travel.plan.dto.PlanScheduleDto; -import com.minizin.travel.plan.entity.Plan; -import com.minizin.travel.plan.entity.PlanBudget; -import com.minizin.travel.plan.entity.PlanSchedule; -import com.minizin.travel.plan.repository.PlanBudgetRepository; -import com.minizin.travel.plan.repository.PlanRepository; -import com.minizin.travel.plan.repository.PlanScheduleRepository; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.format.DateTimeFormatter; - -@SpringBootTest -public class CreatePlanTest { - - private final PlanRepository planRepository; - private final PlanScheduleRepository planScheduleRepository; - private final PlanBudgetRepository planBudgetRepository; - final int INITIAL_VALUE = 0; - - @Autowired - public CreatePlanTest(PlanRepository planRepository, PlanScheduleRepository planScheduleRepository, PlanBudgetRepository planBudgetRepository) { - this.planRepository = planRepository; - this.planScheduleRepository = planScheduleRepository; - this.planBudgetRepository = planBudgetRepository; - } - - @Test - void createPlanTest() { - // given - // PlanDto - PlanDto planDto = PlanDto.builder() - .userId(1L) - .planName("장소 계획 테스트") - .theme("테마 계획 테스트") - .startDate(LocalDate.parse("2024-06-10")) - .endDate(LocalDate.parse("2024-07-01")) - .scope(true) - .numberOfMembers(3) - .build(); - - // PlanScheduleDto - PlanScheduleDto planScheduleDto = PlanScheduleDto.builder() - .scheduleDate(LocalDate.parse("2024-06-01")) - .placeCategory("스케줄 카테고리 테스트") - .placeName("스케줄 이름 테스트") - .region("지역") - .placeMemo("스케줄 메모 테스트") - .arrivalTime("21:00:00") - .build(); - - // PlanBudgetDto - PlanBudgetDto planBudgetDto = PlanBudgetDto.builder().build(); - - // when - // Plan 저장 - Plan newPlan = Plan.builder() - .userId(planDto.getUserId()) - .planName(planDto.getPlanName()) - .theme(planDto.getTheme()) - .startDate(planDto.getStartDate()) - .endDate(planDto.getEndDate()) - .scope(planDto.isScope()) - .numberOfMembers(planDto.getNumberOfMembers()) - .numberOfLikes(INITIAL_VALUE) - .numberOfScraps(INITIAL_VALUE) - .createdAt(LocalDateTime.now()) - .modifiedAt(LocalDateTime.now()) - .build(); - Plan insertedPlan = planRepository.save(newPlan); - - // PlanSchedule 저장 - PlanSchedule newPlanSchedule = PlanSchedule.builder() - .planId(insertedPlan.getId()) - .scheduleDate(planScheduleDto.getScheduleDate()) - .placeCategory(planScheduleDto.getPlaceCategory()) - .placeName(planScheduleDto.getPlaceName()) - .placeMemo(planScheduleDto.getPlaceMemo()) - .arrivalTime(LocalTime.parse(planScheduleDto.getArrivalTime(), DateTimeFormatter.ofPattern("HH:mm:ss"))) - .x(planScheduleDto.getX()) - .y(planScheduleDto.getY()) - .createdAt(LocalDateTime.now()) - .modifiedAt(LocalDateTime.now()) - .build(); - PlanSchedule insertedPlanSchedule = planScheduleRepository.save(newPlanSchedule); - - // PlanBudget 저장 - PlanBudget newPlanBudget = PlanBudget.builder() - .scheduleId(insertedPlanSchedule.getId()) - .budgetCategory(planBudgetDto.getBudgetCategory()) - .cost(planBudgetDto.getCost()) - .budgetMemo(planBudgetDto.getBudgetMemo()) - .createdAt(LocalDateTime.now()) - .modifiedAt(LocalDateTime.now()) - .build(); - PlanBudget insertedPlanBudget = planBudgetRepository.save(newPlanBudget); - - // then - Assertions.assertEquals(newPlan, insertedPlan); - Assertions.assertEquals(newPlanSchedule, insertedPlanSchedule); - Assertions.assertEquals(newPlanBudget, insertedPlanBudget); - } -} diff --git a/src/test/java/com/minizin/travel/plan/SelectListPlanTest.java b/src/test/java/com/minizin/travel/plan/SelectListPlanTest.java deleted file mode 100644 index 078b9d2..0000000 --- a/src/test/java/com/minizin/travel/plan/SelectListPlanTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.minizin.travel.plan; - -import com.minizin.travel.plan.entity.Plan; -import com.minizin.travel.plan.repository.PlanRepository; -import com.minizin.travel.plan.service.PlanService; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -public class SelectListPlanTest { - - private final PlanRepository planRepository; - private final PlanService planService; - - @Autowired - public SelectListPlanTest(PlanRepository planRepository, PlanService planService) { - this.planRepository = planRepository; - this.planService = planService; - } - - @Test - void selectListPlanTest() { - // given - for (int i = 0; i < 10; i++) { - planRepository.save(Plan.builder() - .planName("test") - .userId(1L) - .build()); - } - - // when - Long cursorId = 10L; - var result = planService.selectListPlan(cursorId); - - // then - // cursorId 보다 작은 id 값부터 조회 - Assertions.assertEquals(9, result.getData().get(0).getId()); - // 현재 planService 에 지정된 조회되는 개수가 6개 (id: 9 ~ 4까지 조회) - // nextCursor의 값은 4로 지정 - Assertions.assertEquals(4, result.getNextCursor()); - } -} diff --git a/src/test/java/com/minizin/travel/tour/service/TourServiceTest.java b/src/test/java/com/minizin/travel/tour/service/TourServiceTest.java new file mode 100644 index 0000000..f2b086a --- /dev/null +++ b/src/test/java/com/minizin/travel/tour/service/TourServiceTest.java @@ -0,0 +1,163 @@ +//package com.minizin.travel.tour.service; +// +//import com.minizin.travel.tour.domain.dto.TourAPIDto; +//import com.minizin.travel.tour.domain.entity.TourAPI; +//import com.minizin.travel.tour.domain.repository.TourAPIRepository; +//import com.nimbusds.jose.shaded.gson.Gson; +//import okhttp3.OkHttpClient; +//import okhttp3.mockwebserver.MockResponse; +//import okhttp3.mockwebserver.MockWebServer; +//import org.junit.jupiter.api.*; +//import org.mockito.InjectMocks; +//import org.mockito.Mock; +//import org.mockito.MockitoAnnotations; +//import org.springframework.data.domain.Page; +//import org.springframework.data.domain.PageImpl; +//import org.springframework.data.domain.PageRequest; +// +//import java.io.IOException; +//import java.util.Collections; +//import java.util.List; +//import java.util.concurrent.CompletableFuture; +// +//import static org.junit.jupiter.api.Assertions.assertEquals; +//import static org.mockito.ArgumentMatchers.any; +//import static org.mockito.Mockito.*; +// +///** +// * Class: TourServiceTest Project: travel Package: com.minizin.travel.tour.service +// *

+// * Description: +// * +// * @author dong-hoshin +// * @date 6/27/24 18:34 Copyright (c) 2024 +// * @see GitHub Repository +// */ +//class TourServiceTest { +// +// @Mock +// private TourAPIRepository tourAPIRepository; +// +// @InjectMocks +// private TourService tourService; +// +// private MockWebServer mockWebServer; +// +// private OkHttpClient client; +// +// @BeforeEach +// void setUp() throws IOException { +// MockitoAnnotations.openMocks(this); +// mockWebServer = new MockWebServer(); +// mockWebServer.start(); +// client = new OkHttpClient(); +// } +// +// @AfterEach +// void tearDown() throws IOException { +// mockWebServer.shutdown(); +// } +// +// /*@Test +// void testGetTourAPIFromSiteDetailCommon() throws Exception { +// // given +// TourAPIDto.TourRequest requestParam = new TourAPIDto.TourRequest(); +// requestParam.setPageNo("0"); +// requestParam.setNumOfRows("1"); +// +// TourAPI tourAPI = new TourAPI(); +// tourAPI.setContentId("129459"); +// tourAPI.setContentTypeId("12"); +// Page tourAPIPage = new PageImpl<>(Collections.singletonList(tourAPI)); +// when(tourAPIRepository.findAll(any(PageRequest.class))).thenReturn(tourAPIPage); +// +// mockWebServer.enqueue(new MockResponse() +// .setBody("{\"response\": {\"body\": {\"items\": {\"item\": [{\"contentId\": \"129459\", \"contentTypeId\": \"12\"}]}}}}") +// .addHeader("Content-Type", "application/json")); +// +// // when +// CompletableFuture future = tourService.getTourAPIFromSiteDetailCommon(requestParam); +// String result = future.join(); +// +// // then +// assertEquals("getTourAPIFromSiteDetailCommon executed successfully", result); +// verify(tourAPIRepository, times(1)).findAll(any(PageRequest.class)); +// }*/ +// +// @Test +// void testGetTourAPIFromSiteSearchKeyword() throws Exception { +// // given +// TourAPIDto.TourRequest requestParam = new TourAPIDto.TourRequest(); +// requestParam.setKeyword("test"); +// +// mockWebServer.enqueue(new MockResponse() +// .setBody("{\"response\": {\"body\": {\"items\": {\"item\": [{\"contentId\": \"1\", \"contentTypeId\": \"2\"}]}}}}") +// .addHeader("Content-Type", "application/json")); +// +// // when +// CompletableFuture> future = tourService.getTourAPIFromSiteSearchKeyword(requestParam); +// List result = future.join(); +// +// // then +// assertEquals(1, result.size()); +// assertEquals("1", result.get(0).getContentId()); +// assertEquals("2", result.get(0).getContentTypeId()); +// } +// +// @Test +// void testGetTourAPIFromSiteAreaBasedList() throws Exception { +// // given +// TourAPIDto.TourRequest requestParam = new TourAPIDto.TourRequest(); +// +// mockWebServer.enqueue(new MockResponse() +// .setBody("{\"response\": {\"body\": {\"items\": {\"item\": [{\"contentId\": \"1\", \"contentTypeId\": \"2\"}]}}}}") +// .addHeader("Content-Type", "application/json")); +// +// // when +// CompletableFuture> future = tourService.getTourAPIFromSiteAreaBasedList(requestParam); +// List result = future.join(); +// +// // then +// assertEquals(1, result.size()); +// assertEquals("1", result.get(0).getContentId()); +// assertEquals("2", result.get(0).getContentTypeId()); +// } +// +// @Test +// void testGetTourAPIFromSiteAreaBasedListSingle() throws Exception { +// // given +// TourAPIDto.TourRequest requestParam = new TourAPIDto.TourRequest(); +// +// mockWebServer.enqueue(new MockResponse() +// .setBody("{\"response\": {\"body\": {\"items\": {\"item\": [{\"contentId\": \"1\", \"contentTypeId\": \"2\"}]}}}}") +// .addHeader("Content-Type", "application/json")); +// +// // when +// CompletableFuture> future = tourService.getTourAPIFromSiteAreaBasedListSingle(requestParam); +// List result = future.join(); +// +// // then +// assertEquals(1, result.size()); +// assertEquals("1", result.get(0).getContentId()); +// assertEquals("2", result.get(0).getContentTypeId()); +// } +// +// @Test +// void testGetTourAPIFromSiteAreaCode() throws Exception { +// // given +// TourAPIDto.TourRequest requestParam = new TourAPIDto.TourRequest(); +// +// mockWebServer.enqueue(new MockResponse() +// .setBody("{\"response\": {\"body\": {\"items\": {\"item\": [{\"contentId\": \"1\", \"contentTypeId\": \"2\"}]}}}}") +// .addHeader("Content-Type", "application/json")); +// +// // when +// CompletableFuture> future = tourService.getTourAPIFromSiteAreaCode(requestParam); +// List result = future.join(); +// +// // then +// assertEquals(1, result.size()); +// assertEquals("1", result.get(0).getContentId()); +// assertEquals("2", result.get(0).getContentTypeId()); +// } +//} \ No newline at end of file diff --git a/src/test/java/com/minizin/travel/user/service/AuthServiceTest.java b/src/test/java/com/minizin/travel/user/service/AuthServiceTest.java new file mode 100644 index 0000000..710a3fe --- /dev/null +++ b/src/test/java/com/minizin/travel/user/service/AuthServiceTest.java @@ -0,0 +1,91 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.JoinDto; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.LoginType; +import com.minizin.travel.user.domain.enums.Role; +import com.minizin.travel.user.domain.enums.UserErrorCode; +import com.minizin.travel.user.domain.exception.CustomUserException; +import com.minizin.travel.user.domain.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private BCryptPasswordEncoder bCryptPasswordEncoder; + + @InjectMocks + private AuthService authService; + + @Test + @DisplayName("회원가입 성공") + void join_success() { + //given + JoinDto.Request request = new JoinDto.Request(); + request.setUsername("username"); + request.setPassword("password"); + request.setNickname("nickname"); + request.setEmail("email"); + + given(userRepository.existsByUsername(request.getUsername())) + .willReturn(false); + given(bCryptPasswordEncoder.encode(request.getPassword())) + .willReturn("password"); + given(userRepository.save(any(UserEntity.class))) + .willReturn(UserEntity.builder() + .username("username") + .password("password") + .nickname("nickname") + .email("email") + .role(Role.ROLE_USER) + .loginType(LoginType.LOCAL) + .build()); + + //when + JoinDto.Response response = authService.join(request); + + //then + assertEquals("username", response.getUsername()); + assertEquals("password", response.getPassword()); + assertEquals("nickname", response.getNickname()); + assertEquals("email", response.getEmail()); + assertEquals(Role.ROLE_USER, response.getRole()); + assertEquals(LoginType.LOCAL, response.getLoginType()); + + verify(userRepository, times(1)).save(any(UserEntity.class)); + } + + @Test + @DisplayName("회원가입 실패 - 이미 존재하는 사용자") + void join_fail_alreadyExistsUser() { + //given + JoinDto.Request request = new JoinDto.Request(); + request.setUsername("username"); + given(userRepository.existsByUsername("username")) + .willReturn(true); + + //when + CustomUserException customUserException = assertThrows(CustomUserException.class, + () -> authService.join(request)); + + //then + assertEquals(UserErrorCode.USER_ALREADY_EXIST, customUserException.getUserErrorCode()); + } +} \ No newline at end of file diff --git a/src/test/java/com/minizin/travel/user/service/CustomOAuth2UserServiceTest.java b/src/test/java/com/minizin/travel/user/service/CustomOAuth2UserServiceTest.java new file mode 100644 index 0000000..c6ee975 --- /dev/null +++ b/src/test/java/com/minizin/travel/user/service/CustomOAuth2UserServiceTest.java @@ -0,0 +1,82 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.LoginType; +import com.minizin.travel.user.domain.enums.Role; +import com.minizin.travel.user.domain.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CustomOAuth2UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private DefaultOAuth2UserService delegate; + + @InjectMocks + private CustomOAuth2UserService customOAuth2UserService; + + @Test + @DisplayName("소셜 로그인 시 사용자 정보 획득 성공") + void loadUser_success() { + //given + OAuth2UserRequest userRequest = mock(OAuth2UserRequest.class); + OAuth2User oAuth2User = mock(OAuth2User.class); + given(delegate.loadUser(userRequest)).willReturn(oAuth2User); + + ClientRegistration clientRegistration = mock(ClientRegistration.class); + when(userRequest.getClientRegistration()).thenReturn(clientRegistration); + when(clientRegistration.getRegistrationId()).thenReturn("kakao"); + + Map attributes = Map.of( + "id", "12345", + "kakao_account", Map.of( + "email", "test@example.com", + "profile", Map.of("nickname", "Test User") + ) + ); + when(oAuth2User.getAttributes()).thenReturn(attributes); + given(userRepository.findByUsername("kakao 12345")) + .willReturn(Optional.empty()); + given(userRepository.save(any(UserEntity.class))) + .willReturn(UserEntity.builder() + .username("kakao 12345") + .email("test@example.com") + .nickname("Test User") + .role(Role.ROLE_USER) + .loginType(LoginType.KAKAO) + .build()); + + //when + OAuth2User oAuth2User1 = customOAuth2UserService.loadUser(userRequest); + + //then + assertNotNull(oAuth2User1); + assertEquals("kakao 12345", ((PrincipalDetails) oAuth2User1).getUsername()); + assertEquals("test@example.com", ((PrincipalDetails) oAuth2User1).getUserEntity().getEmail()); + assertEquals("Test User", ((PrincipalDetails) oAuth2User1).getUserEntity().getNickname()); + + verify(userRepository, times(1)).save(any(UserEntity.class)); + } + +} \ No newline at end of file diff --git a/src/test/java/com/minizin/travel/user/service/CustomUserDetailsServiceTest.java b/src/test/java/com/minizin/travel/user/service/CustomUserDetailsServiceTest.java new file mode 100644 index 0000000..5eba119 --- /dev/null +++ b/src/test/java/com/minizin/travel/user/service/CustomUserDetailsServiceTest.java @@ -0,0 +1,75 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.PrincipalDetails; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.LoginType; +import com.minizin.travel.user.domain.enums.Role; +import com.minizin.travel.user.domain.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class CustomUserDetailsServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private CustomUserDetailsService customUserDetailsService; + + @Test + @DisplayName("username 으로 principalDetails 획득 성공") + void loadUserByUsername_success() { + //given + given(userRepository.findByUsername("username")) + .willReturn(Optional.of( + UserEntity.builder() + .username("username") + .password("password") + .email("email") + .nickname("nickname") + .role(Role.ROLE_USER) + .loginType(LoginType.LOCAL) + .build() + )); + + //when + UserDetails userDetails = customUserDetailsService.loadUserByUsername("username"); + + //then + assertEquals("username", ((PrincipalDetails) userDetails).getUserEntity().getUsername()); + assertEquals("password", ((PrincipalDetails) userDetails).getUserEntity().getPassword()); + assertEquals("email", ((PrincipalDetails) userDetails).getUserEntity().getEmail()); + assertEquals("nickname", ((PrincipalDetails) userDetails).getUserEntity().getNickname()); + assertEquals(Role.ROLE_USER, ((PrincipalDetails) userDetails).getUserEntity().getRole()); + assertEquals(LoginType.LOCAL, ((PrincipalDetails) userDetails).getUserEntity().getLoginType()); + } + + @Test + @DisplayName("username 으로 principalDetails 획득 실패 - 존재하지 않는 사용자") + void loadUserByUsername_fail_UserNotFound() { + //given + given(userRepository.findByUsername("username")) + .willReturn(Optional.empty()); + + //when + UsernameNotFoundException exception = assertThrows(UsernameNotFoundException.class, + () -> customUserDetailsService.loadUserByUsername("username")); + + //then + assertEquals("존재하지 않는 사용자입니다.", exception.getMessage()); + + } +} \ No newline at end of file diff --git a/src/test/java/com/minizin/travel/user/service/MailServiceTest.java b/src/test/java/com/minizin/travel/user/service/MailServiceTest.java new file mode 100644 index 0000000..5751bb3 --- /dev/null +++ b/src/test/java/com/minizin/travel/user/service/MailServiceTest.java @@ -0,0 +1,143 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.FindPasswordDto; +import com.minizin.travel.user.domain.dto.SendAuthCodeDto; +import com.minizin.travel.user.domain.dto.VerifyAuthCodeDto; +import com.minizin.travel.user.domain.enums.MailErrorCode; +import com.minizin.travel.user.domain.exception.CustomMailException; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MailServiceTest { + + @Mock + private JavaMailSender javaMailSender; + + @Mock + private RedisService redisService; + + @InjectMocks + private MailService mailService; + + @Test + @DisplayName("인증 코드 메일 발송 성공") + void sendAuthCode_success() { + //given + SendAuthCodeDto sendAuthCodeDto = new SendAuthCodeDto(); + sendAuthCodeDto.setEmail("email"); + + MimeMessage mimeMessage = mock(MimeMessage.class); + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + //when + mailService.sendAuthCode(sendAuthCodeDto); + + //then + verify(javaMailSender, times(1)).send(any(MimeMessage.class)); + } + + @Test + @DisplayName("인증 코드 메일 발송 실패") + void sendAuthCode_fail() { + //given + SendAuthCodeDto sendAuthCodeDto = new SendAuthCodeDto(); + sendAuthCodeDto.setEmail("email"); + + given(javaMailSender.createMimeMessage()) + .willReturn(null); + + //when + CustomMailException exception = assertThrows(CustomMailException.class, + () -> mailService.sendAuthCode(sendAuthCodeDto)); + + //then + assertEquals(MailErrorCode.AUTH_CODE_SEND_FAILED, exception.getMailErrorCode()); + } + + @Test + @DisplayName("인증 코드 일치") + void verifyAuthCode_true() { + //given + VerifyAuthCodeDto verifyAuthCodeDto = new VerifyAuthCodeDto(); + verifyAuthCodeDto.setEmail("email"); + verifyAuthCodeDto.setAuthCode("authCode"); + + given(redisService.getData("email")) + .willReturn("authCode"); + + //when + Boolean verified = mailService.verifyAuthCode(verifyAuthCodeDto); + + //then + assertEquals(true, verified); + } + + @Test + @DisplayName("인증 코드 불일치") + void verifyAuthCode_false() { + //given + VerifyAuthCodeDto verifyAuthCodeDto = new VerifyAuthCodeDto(); + verifyAuthCodeDto.setEmail("email"); + verifyAuthCodeDto.setAuthCode("authCode"); + + given(redisService.getData("email")) + .willReturn("authCode2"); + + //when + Boolean verified = mailService.verifyAuthCode(verifyAuthCodeDto); + + //then + assertEquals(false, verified); + } + + @Test + @DisplayName("임시 비밀번호 메일 발송 성공") + void sendTemporaryPassword_success() { + //given + FindPasswordDto.Request request = new FindPasswordDto.Request(); + request.setEmail("email"); + request.setUsername("username"); + + MimeMessage mimeMessage = mock(MimeMessage.class); + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + //when + mailService.sendTemporaryPassword(request); + + //then + verify(javaMailSender, times(1)).send(any(MimeMessage.class)); + } + + @Test + @DisplayName("임시 비밀번호 메일 발송 실패") + void sendTemporaryPassword_fail() { + //given + FindPasswordDto.Request request = new FindPasswordDto.Request(); + request.setEmail("email"); + request.setUsername("username"); + + given(javaMailSender.createMimeMessage()) + .willReturn(null); + + //when + CustomMailException exception = assertThrows(CustomMailException.class, + () -> mailService.sendTemporaryPassword(request)); + + //then + assertEquals(MailErrorCode.TEMPORARY_PASSWORD_SEND_FAILED, exception.getMailErrorCode()); + } +} \ No newline at end of file diff --git a/src/test/java/com/minizin/travel/user/service/RedisServiceTest.java b/src/test/java/com/minizin/travel/user/service/RedisServiceTest.java new file mode 100644 index 0000000..8f57ece --- /dev/null +++ b/src/test/java/com/minizin/travel/user/service/RedisServiceTest.java @@ -0,0 +1,64 @@ +package com.minizin.travel.user.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RedisServiceTest { + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @InjectMocks + private RedisService redisService; + + @Test + @DisplayName("인증 코드 만료 시간과 함께 저장") + void saveWithExpirationTime() { + //given + String key = "key"; + String value = "value"; + long duration = 10L; + + ValueOperations valueOperations = mock(ValueOperations.class); + given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); + + //when + redisService.saveWithExpirationTime(key, value, duration); + + //then + verify(valueOperations, times(1)).set(eq(key), eq(value), + eq(Duration.ofSeconds(duration))); + } + + @Test + @DisplayName("데이터 가져오기") + void getData() { + //given + String key = "key"; + String value = "value"; + + ValueOperations valueOperations = mock(ValueOperations.class); + given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); + when(valueOperations.get(key)).thenReturn(value); + + //when + String data = redisService.getData(key); + + //then + assertEquals(value, data); + } + +} \ No newline at end of file diff --git a/src/test/java/com/minizin/travel/user/service/UserServiceTest.java b/src/test/java/com/minizin/travel/user/service/UserServiceTest.java new file mode 100644 index 0000000..62ee844 --- /dev/null +++ b/src/test/java/com/minizin/travel/user/service/UserServiceTest.java @@ -0,0 +1,403 @@ +package com.minizin.travel.user.service; + +import com.minizin.travel.user.domain.dto.*; +import com.minizin.travel.user.domain.entity.UserEntity; +import com.minizin.travel.user.domain.enums.LoginType; +import com.minizin.travel.user.domain.enums.UserErrorCode; +import com.minizin.travel.user.domain.exception.CustomUserException; +import com.minizin.travel.user.domain.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private MailService mailService; + + @Mock + private BCryptPasswordEncoder bCryptPasswordEncoder; + + @InjectMocks + private UserService userService; + + @Test + @DisplayName("사용자 정보 조회 성공") + void getUserInfo_success() { + //given + UserEntity userEntity = UserEntity.builder() + .id(1L) + .username("username") + .email("email") + .nickname("nickname") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.of(userEntity)); + + //when + UserDto.Response response = userService.getUserInfo(principalDetails); + + //then + assertEquals(1L, response.getUserId()); + assertEquals("username", response.getUsername()); + assertEquals("email", response.getEmail()); + assertEquals("nickname", response.getNickname()); + } + + @Test + @DisplayName("사용자 정보 조회 실패 - 존재하지 않는 사용자") + void getUserInfo_fail_userNotFound() { + //given + UserEntity userEntity = UserEntity.builder() + .username("username") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.empty()); + + //when + CustomUserException exception = assertThrows(CustomUserException.class, + () -> userService.getUserInfo(principalDetails)); + + //then + assertEquals(UserErrorCode.USER_NOT_FOUND, exception.getUserErrorCode()); + } + + @Test + @DisplayName("아이디 찾기 성공") + void findId_success() { + //given + FindIdDto.Request request = new FindIdDto.Request(); + request.setEmail("email"); + given(userRepository.findByEmailAndLoginType(request.getEmail(), LoginType.LOCAL)) + .willReturn(Optional.of( + UserEntity.builder() + .username("username") + .build() + )); + + + //when + FindIdDto.Response response = userService.findId(request); + + //then + assertEquals("username", response.getUsername()); + } + + @Test + @DisplayName("아이디 찾기 실패 - 등록되지 않은 이메일") + void findId_fail_unRegisteredEmail() { + //given + FindIdDto.Request request = new FindIdDto.Request(); + request.setEmail("email"); + given(userRepository.findByEmailAndLoginType(request.getEmail(), LoginType.LOCAL)) + .willReturn(Optional.empty()); + + //when + CustomUserException exception = assertThrows(CustomUserException.class, + () -> userService.findId(request)); + + //then + assertEquals(UserErrorCode.EMAIL_UN_REGISTERED, exception.getUserErrorCode()); + } + + @Test + @DisplayName("임시 비밀번호 발급 성공") + void findPassword_success() { + //given + FindPasswordDto.Request request = new FindPasswordDto.Request(); + request.setUsername("username"); + request.setEmail("email"); + UserEntity userEntity = UserEntity.builder() + .username("username") + .email("email") + .build(); + given(userRepository.findByUsername(request.getUsername())) + .willReturn(Optional.of(userEntity)); + given(mailService.sendTemporaryPassword(request)) + .willReturn("password"); + given(bCryptPasswordEncoder.encode("password")) + .willReturn("password"); + + //when + userService.findPassword(request); + + //then + assertEquals("password", userEntity.getPassword()); + } + + @Test + @DisplayName("임시 비밀번호 발급 실패 - 존재하지 않는 사용자") + void findPassword_fail_userNotFound() { + //given + FindPasswordDto.Request request = new FindPasswordDto.Request(); + request.setUsername("username"); + request.setEmail("email"); + given(userRepository.findByUsername(request.getUsername())) + .willReturn(Optional.empty()); + + //when + CustomUserException exception = assertThrows(CustomUserException.class, + () -> userService.findPassword(request)); + + //then + assertEquals(UserErrorCode.USER_NOT_FOUND, exception.getUserErrorCode()); + } + + @Test + @DisplayName("임시 비밀번호 발급 실패 - 사용자에게 등록된 이메일이 아님") + void findPassword_fail_userEmailUnMatched() { + //given + FindPasswordDto.Request request = new FindPasswordDto.Request(); + request.setUsername("username"); + request.setEmail("email2"); + UserEntity userEntity = UserEntity.builder() + .username("username") + .email("email") + .build(); + given(userRepository.findByUsername(request.getUsername())) + .willReturn(Optional.of(userEntity)); + + //when + CustomUserException exception = assertThrows(CustomUserException.class, + () -> userService.findPassword(request)); + + //then + assertEquals(UserErrorCode.USER_EMAIL_UN_MATCHED, exception.getUserErrorCode()); + } + + @Test + @DisplayName("비밀번호 수정 성공") + void updatePassword_success() { + //given + UpdatePasswordDto.Request request = new UpdatePasswordDto.Request(); + request.setOriginalPassword("original"); + request.setChangePassword("change"); + + UserEntity userEntity = UserEntity.builder() + .id(1L) + .username("username") + .password("original") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.of(userEntity)); + given(bCryptPasswordEncoder.matches(request.getOriginalPassword(), userEntity.getPassword())) + .willReturn(true); + given(bCryptPasswordEncoder.encode(request.getChangePassword())) + .willReturn("change"); + + //when + UpdatePasswordDto.Response response = userService.updatePassword(request, principalDetails); + + //then + assertEquals(true, response.getSuccess()); + assertEquals(1L, response.getUserId()); + assertEquals("change", response.getChangedPassword()); + + } + + @Test + @DisplayName("비밀번호 수정 실패 - 존재하지 않는 사용자") + void updatePassword_fail_userNotFound() { + //given + UpdatePasswordDto.Request request = new UpdatePasswordDto.Request(); + + UserEntity userEntity = UserEntity.builder() + .username("username") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.empty()); + + //when + CustomUserException exception = assertThrows(CustomUserException.class, + () -> userService.updatePassword(request, principalDetails)); + + //then + assertEquals(UserErrorCode.USER_NOT_FOUND, exception.getUserErrorCode()); + + } + + @Test + @DisplayName("비밀번호 수정 실패 - 일치하지 않는 비밀번호") + void updatePassword_fail_passwordUnMatched() { + //given + UpdatePasswordDto.Request request = new UpdatePasswordDto.Request(); + request.setOriginalPassword("original"); + request.setChangePassword("change"); + + UserEntity userEntity = UserEntity.builder() + .id(1L) + .username("username") + .password("password") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.of(userEntity)); + given(bCryptPasswordEncoder.matches(request.getOriginalPassword(), userEntity.getPassword())) + .willReturn(false); + + //when + CustomUserException exception = assertThrows(CustomUserException.class, + () -> userService.updatePassword(request, principalDetails)); + + //then + assertEquals(UserErrorCode.PASSWORD_UN_MATCHED, exception.getUserErrorCode()); + + } + + @Test + @DisplayName("이메일 수정 성공") + void updateEmail_success() { + //given + UpdateEmailDto.Request request = new UpdateEmailDto.Request(); + request.setEmail("email2"); + UserEntity userEntity = UserEntity.builder() + .id(1L) + .username("username") + .email("email") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.of(userEntity)); + + //when + UpdateEmailDto.Response response = userService.updateEmail(request, principalDetails); + + //then + assertEquals(true, response.getSuccess()); + assertEquals(1L, response.getUserId()); + assertEquals("email2", response.getChangedEmail()); + } + + @Test + @DisplayName("이메일 수정 실패 - 존재하지 않는 사용자") + void updateEmail_fail_userNotFound() { + //given + UpdateEmailDto.Request request = new UpdateEmailDto.Request(); + UserEntity userEntity = UserEntity.builder() + .username("username") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.empty()); + + //when + CustomUserException exception = assertThrows(CustomUserException.class, + () -> userService.updateEmail(request, principalDetails)); + + //then + assertEquals(UserErrorCode.USER_NOT_FOUND, exception.getUserErrorCode()); + + } + + @Test + @DisplayName("닉네임 수정 성공") + void updateNickname_success() { + //given + UpdateNicknameDto.Request request = new UpdateNicknameDto.Request(); + request.setNickname("nickname2"); + UserEntity userEntity = UserEntity.builder() + .id(1L) + .username("username") + .nickname("nickname") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.of(userEntity)); + + //when + UpdateNicknameDto.Response response = userService.updateNickname(request, principalDetails); + + //then + assertEquals(true, response.getSuccess()); + assertEquals(1L, response.getUserId()); + assertEquals("nickname2", response.getChangedNickname()); + } + + @Test + @DisplayName("닉네임 수정 실패 - 존재하지 않는 사용자") + void updateNickname_fail_userNotFound() { + //given + UpdateNicknameDto.Request request = new UpdateNicknameDto.Request(); + UserEntity userEntity = UserEntity.builder() + .username("username") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.empty()); + + //when + CustomUserException exception = assertThrows(CustomUserException.class, + () -> userService.updateNickname(request, principalDetails)); + + //then + assertEquals(UserErrorCode.USER_NOT_FOUND, exception.getUserErrorCode()); + + } + + @Test + @DisplayName("사용자 삭제 성공") + void deleteUser_success() { + //given + UserEntity userEntity = UserEntity.builder() + .id(1L) + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.of(userEntity)); + + //when + DeleteUserDto.Response response = userService.deleteUser(principalDetails); + + //then + assertEquals(true, response.getSuccess()); + assertEquals(1L, response.getUserId()); + } + + @Test + @DisplayName("사용자 삭제 실패 - 존재하지 않는 사용자") + void deleteUser_fail_userNotFound() { + //given + UserEntity userEntity = UserEntity.builder() + .username("username") + .build(); + PrincipalDetails principalDetails = new PrincipalDetails(userEntity); + + given(userRepository.findByUsername(principalDetails.getUsername())) + .willReturn(Optional.empty()); + + //when + CustomUserException exception = assertThrows(CustomUserException.class, + () -> userService.deleteUser(principalDetails)); + + //then + assertEquals(UserErrorCode.USER_NOT_FOUND, exception.getUserErrorCode()); + } +} \ No newline at end of file