diff --git a/.gitignore b/.gitignore index c2065bc..ba9d2be 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ bin/ out/ !**/src/main/**/out/ !**/src/test/**/out/ +application.yml ### NetBeans ### /nbproject/private/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f651ce5 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Spring-JPA-study diff --git a/build.gradle b/build.gradle index 15b77ef..5c5e43c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.0.1' - id 'io.spring.dependency-management' version '1.1.0' + id 'org.springframework.boot' version '2.7.8' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' } group = 'com.dku' version = '0.0.1-SNAPSHOT' -sourceCompatibility = '17' +sourceCompatibility = '11' configurations { compileOnly { @@ -27,6 +27,22 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Jwt + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // Http + implementation 'org.apache.httpcomponents:httpclient:4.5.7' + + // Swagger + implementation 'io.springfox:springfox-boot-starter:3.0.0' + implementation 'io.springfox:springfox-swagger-ui:3.0.0' + + // AWS S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { diff --git a/document/ERD.png b/document/ERD.png new file mode 100644 index 0000000..57e7e8c Binary files /dev/null and b/document/ERD.png differ diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/gradlew.bat b/gradlew.bat index 53a6b23..f127cfd 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,91 +1,91 @@ -@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=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@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=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/com/dku/springstudy/SpringStudyApplication.java b/src/main/java/com/dku/springstudy/SpringStudyApplication.java index ef164c9..1bef475 100644 --- a/src/main/java/com/dku/springstudy/SpringStudyApplication.java +++ b/src/main/java/com/dku/springstudy/SpringStudyApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class SpringStudyApplication { diff --git a/src/main/java/com/dku/springstudy/config/InterceptorConfig.java b/src/main/java/com/dku/springstudy/config/InterceptorConfig.java new file mode 100644 index 0000000..d8c5b47 --- /dev/null +++ b/src/main/java/com/dku/springstudy/config/InterceptorConfig.java @@ -0,0 +1,20 @@ +//package com.dku.springstudy.config; +// +//import com.dku.springstudy.dto.common.interceptor.HttpInterceptor; +//import lombok.RequiredArgsConstructor; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +//import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +// +//@Configuration +//@RequiredArgsConstructor +//public class InterceptorConfig implements WebMvcConfigurer { +// +// private final HttpInterceptor httpInterceptor; +// +// @Override +// public void addInterceptors(InterceptorRegistry registry) { +// registry.addInterceptor(httpInterceptor) // 만든 인터셉터 등록 +// .addPathPatterns("/**"); +// } +//} diff --git a/src/main/java/com/dku/springstudy/config/SecurityConfig.java b/src/main/java/com/dku/springstudy/config/SecurityConfig.java new file mode 100644 index 0000000..a631aff --- /dev/null +++ b/src/main/java/com/dku/springstudy/config/SecurityConfig.java @@ -0,0 +1,57 @@ +package com.dku.springstudy.config; + +import com.dku.springstudy.security.jwt.JwtAuthenticationFilter; +import com.dku.springstudy.security.jwt.JwtTokenProvider; +import com.dku.springstudy.security.exception.JwtAccessDeniedHandler; +import com.dku.springstudy.security.exception.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Slf4j +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf().disable() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 X + .and() + .authorizeRequests() + .antMatchers("/api/sign-up", "/api/login").permitAll() + .antMatchers("/v3/api-docs/**", "/swagger*/**").permitAll() + .anyRequest().authenticated() + .and() + .formLogin() + .usernameParameter("email") // 식별 데이터를 email로 사용 + .and() + .exceptionHandling() + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + .and() + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/com/dku/springstudy/config/SwaggerConfig.java b/src/main/java/com/dku/springstudy/config/SwaggerConfig.java new file mode 100644 index 0000000..ab4d435 --- /dev/null +++ b/src/main/java/com/dku/springstudy/config/SwaggerConfig.java @@ -0,0 +1,61 @@ +package com.dku.springstudy.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import springfox.documentation.builders.ApiInfoBuilder; +import springfox.documentation.builders.PathSelectors; +import springfox.documentation.builders.RequestHandlerSelectors; +import springfox.documentation.service.ApiInfo; +import springfox.documentation.service.ApiKey; +import springfox.documentation.service.AuthorizationScope; +import springfox.documentation.service.SecurityReference; +import springfox.documentation.spi.DocumentationType; +import springfox.documentation.spi.service.contexts.SecurityContext; +import springfox.documentation.spring.web.plugins.Docket; + +import java.util.Arrays; +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Bean + public Docket api() { + return new Docket(DocumentationType.OAS_30) + .ignoredParameterTypes(AuthenticationPrincipal.class) + .securityContexts(Arrays.asList(securityContext())) + .securitySchemes(Arrays.asList(apiKey())) + .useDefaultResponseMessages(false) + .apiInfo(apiInfo()) + .select() + .apis(RequestHandlerSelectors.basePackage("com.dku.springstudy.controller")) + .paths(PathSelectors.ant("/api/**")) + .build(); + } + + private ApiInfo apiInfo() { + return new ApiInfoBuilder() + .title("D-coding 당근마켓 클론 API") + .description("당근마켓 클론 API 명세서") + .version("v0.0.1") + .build(); + } + + private SecurityContext securityContext() { + return SecurityContext.builder() + .securityReferences(defaultAuth()) + .build(); + } + + private List defaultAuth() { + AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything"); + AuthorizationScope[] authorizationScopes = new AuthorizationScope[1]; + authorizationScopes[0] = authorizationScope; + return Arrays.asList(new SecurityReference("Authorization", authorizationScopes)); + } + + private ApiKey apiKey() { + return new ApiKey("Authorization", "Authorization", "header"); + } +} diff --git a/src/main/java/com/dku/springstudy/controller/AuthController.java b/src/main/java/com/dku/springstudy/controller/AuthController.java new file mode 100644 index 0000000..71336e5 --- /dev/null +++ b/src/main/java/com/dku/springstudy/controller/AuthController.java @@ -0,0 +1,65 @@ +package com.dku.springstudy.controller; + +import com.dku.springstudy.dto.common.SuccessResponse; +import com.dku.springstudy.dto.auth.request.LoginRequestDto; +import com.dku.springstudy.dto.auth.request.SignUpRequestDto; +import com.dku.springstudy.dto.auth.response.LoginResponseDto; +import com.dku.springstudy.dto.auth.response.SignUpResponseDto; +import com.dku.springstudy.service.AuthService; +import io.swagger.annotations.Api; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@Api(tags = "인증 API") +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation( + summary = "회원가입", + description = "사용자에게 회원가입에 필요한 데이터를 입력받고, 가입되지 않는 이메일이라면 회원가입을 진행한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "409", description = "이미 가입된 이메일로 회원가입을 시도하는 경우") + }) + @PostMapping("/sign-up") + public ResponseEntity> signUp(@Valid @RequestBody SignUpRequestDto signUpRequestDto) { + SignUpResponseDto userId = authService.signUp(signUpRequestDto); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new SuccessResponse<>(userId)); + } + + @Operation( + summary = "로그인", + description = "사용자로부터 입력받은 아이디와 비밀번호가 존재한다면 access-token과 refresh-token을 발급한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "404", description = "이메일(ID)이 존재하지 않는 경우"), + @ApiResponse(responseCode = "401", description = "비밀번호가 일치하지 않는 경우") + }) + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody LoginRequestDto loginRequestDto) { + LoginResponseDto tokens = authService.login(loginRequestDto); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new SuccessResponse<>(tokens)); + } +} diff --git a/src/main/java/com/dku/springstudy/controller/LikeController.java b/src/main/java/com/dku/springstudy/controller/LikeController.java new file mode 100644 index 0000000..597deb7 --- /dev/null +++ b/src/main/java/com/dku/springstudy/controller/LikeController.java @@ -0,0 +1,48 @@ +package com.dku.springstudy.controller; + +import com.dku.springstudy.dto.common.SuccessResponse; +import com.dku.springstudy.dto.like.LikeClickResponseDto; +import com.dku.springstudy.security.CustomUserDetails; +import com.dku.springstudy.service.LikeService; +import io.swagger.annotations.Api; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Api(tags = "좋아요 API") +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class LikeController { + + private final LikeService likeService; + + @Operation( + summary = "좋아요 추가 / 취소", + description = "좋아요를 누른 적이 있다면 좋아요를 취소하고, 누른 적이 없다면 좋아요를 추가한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "404", description = "회원 또는 상품 아이디(PK)가 존재하지 않는 경우"), + }) + @PostMapping("/like/{productId}") + public ResponseEntity> clickLikeBtn( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @PathVariable Long productId + ) { + + LikeClickResponseDto response = likeService.clickLikeBtn(customUserDetails.getId(), productId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new SuccessResponse<>(response)); + } +} diff --git a/src/main/java/com/dku/springstudy/controller/ProductController.java b/src/main/java/com/dku/springstudy/controller/ProductController.java new file mode 100644 index 0000000..49c909a --- /dev/null +++ b/src/main/java/com/dku/springstudy/controller/ProductController.java @@ -0,0 +1,76 @@ +package com.dku.springstudy.controller; + +import com.dku.springstudy.dto.common.SuccessResponse; +import com.dku.springstudy.dto.product.request.ProductCreateRequestDto; +import com.dku.springstudy.dto.product.response.ProductCreateResponseDto; +import com.dku.springstudy.dto.product.response.ProductInfoResponseDto; +import com.dku.springstudy.security.CustomUserDetails; +import com.dku.springstudy.service.ProductService; +import io.swagger.annotations.Api; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import java.util.List; + +@Api(tags = "상품 API") +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @Operation( + summary = "상품 등록", + description = "로그인된 사용자가 상품의 사진 / 제목 / 카테고리 / 가격 / 내용을 입력하면 상품 글을 등록한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "404", description = "아이디(PK)가 존재하지 않는 경우"), + @ApiResponse(responseCode = "500", description = "파일의 업로드가 실패했거나 파일 확장자가 올바르지 않는 경우") + }) + @PostMapping("/product") + public ResponseEntity> createPost( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @Parameter(description = "data 키 값으로 CreateRequestDto의 필드들을 입력한다.") + @Valid @RequestPart("data") ProductCreateRequestDto dto, + @Parameter(description = "file 키 값으로 이미지들을 입력한다.") + @RequestPart(value = "file", required = false) List file + ) { + + ProductCreateResponseDto response = productService.createPost(dto, file, customUserDetails.getId()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new SuccessResponse<>(response)); + } + + @Operation( + summary = "상품 상세 조회", + description = "상품 아이디를 입력받아 상세 정보를 조회한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "404", description = "회원 또는 상품의 아이디(PK)가 존재하지 않는 경우"), + }) + @GetMapping("/product/{productId}") + public ResponseEntity> getProductInfo( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @PathVariable("productId") Long productId + ) { + ProductInfoResponseDto response = productService.getProductInfo(customUserDetails.getId(), productId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new SuccessResponse<>(response)); + } +} diff --git a/src/main/java/com/dku/springstudy/controller/UserController.java b/src/main/java/com/dku/springstudy/controller/UserController.java new file mode 100644 index 0000000..e600f6d --- /dev/null +++ b/src/main/java/com/dku/springstudy/controller/UserController.java @@ -0,0 +1,66 @@ +package com.dku.springstudy.controller; + +import com.dku.springstudy.dto.common.SuccessResponse; +import com.dku.springstudy.dto.user.response.UserUpdateResponseDto; +import com.dku.springstudy.security.CustomUserDetails; +import com.dku.springstudy.service.UserService; +import io.swagger.annotations.Api; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; + +@Api(tags = "사용자 API") +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @Operation( + summary = "회원 프로필 수정", + description = "사용자로부터 닉네임과 사진을 입력받아 회원의 정보를 수정한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "404", description = "아이디(PK)가 존재하지 않는 경우"), + }) + @PatchMapping("/user/profile") + public ResponseEntity> update( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @Valid @RequestPart(value = "nickname") String nickname, + @RequestPart(value = "profile", required = false) MultipartFile file + ) { + UserUpdateResponseDto response = userService.update(customUserDetails.getId(), nickname, file); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new SuccessResponse<>(response)); + } + + @Operation( + summary = "회원 프로필 사진 삭제", + description = "로그인된 사용자의 프로필 사진을 삭제한다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success"), + @ApiResponse(responseCode = "404", description = "아이디(PK)가 존재하지 않는 경우"), + }) + @DeleteMapping("/user/profile") + public ResponseEntity> deleteImg(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + + UserUpdateResponseDto response = userService.deleteImg(customUserDetails.getId()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new SuccessResponse<>(response)); + } +} diff --git a/src/main/java/com/dku/springstudy/domain/Likes.java b/src/main/java/com/dku/springstudy/domain/Likes.java new file mode 100644 index 0000000..2614af3 --- /dev/null +++ b/src/main/java/com/dku/springstudy/domain/Likes.java @@ -0,0 +1,34 @@ +package com.dku.springstudy.domain; + +import com.dku.springstudy.domain.common.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +public class Likes extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "like_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Builder + private Likes(User user, Product product) { + this.user = user; + this.product = product; + } +} diff --git a/src/main/java/com/dku/springstudy/domain/Product.java b/src/main/java/com/dku/springstudy/domain/Product.java new file mode 100644 index 0000000..8b0ba6d --- /dev/null +++ b/src/main/java/com/dku/springstudy/domain/Product.java @@ -0,0 +1,63 @@ +package com.dku.springstudy.domain; + +import com.dku.springstudy.domain.common.BaseTimeEntity; +import com.dku.springstudy.domain.constant.Category; +import com.dku.springstudy.domain.constant.Status; +import com.dku.springstudy.s3.domain.File; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.util.ArrayList; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +public class Product extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "product_id") + private Long id; + + @JoinColumn(name = "user_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + @Column(nullable = false) + private Integer price; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Category category; + + @Column(nullable = false) + private Integer likeCount; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Status status; + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private List imageUrls = new ArrayList<>(); + + @Builder + private Product(User user, String title, String content, Integer price, Category category) { + this.user = user; + this.title = title; + this.content = content; + this.price = price; + this.category = category; + this.likeCount = 0; + this.status = status.PROGRESS; + } +} diff --git a/src/main/java/com/dku/springstudy/domain/User.java b/src/main/java/com/dku/springstudy/domain/User.java new file mode 100644 index 0000000..dd89dc8 --- /dev/null +++ b/src/main/java/com/dku/springstudy/domain/User.java @@ -0,0 +1,71 @@ +package com.dku.springstudy.domain; + +import com.dku.springstudy.domain.common.BaseTimeEntity; +import com.dku.springstudy.domain.constant.Role; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.util.ArrayList; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +public class User extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(unique = true, nullable = false) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(unique = true, nullable = false) + private String phoneNumber; + + @Column(unique = true, nullable = false) + private String nickname; + + @Column(nullable = false) + private Integer status; + + @Enumerated(EnumType.STRING) + private Role role; + + private String ImgUrl; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List productList = new ArrayList<>(); + + @Builder + private User(String email, String password, String name, String phoneNumber, String nickname) { + this.email = email; + this.password = password; + this.name = name; + this.phoneNumber = phoneNumber; + this.nickname = nickname; + this.status = 1; + this.role = role.USER; + } + + // 회원 정보 수정 + public void update(String nickname, String ImgUrl) { + this.nickname = nickname; + this.ImgUrl = ImgUrl; + } + + // 프로필 사진 삭제 + public void deleteImg() { + this.ImgUrl = null; + } +} diff --git a/src/main/java/com/dku/springstudy/domain/common/BaseTimeEntity.java b/src/main/java/com/dku/springstudy/domain/common/BaseTimeEntity.java new file mode 100644 index 0000000..06716e7 --- /dev/null +++ b/src/main/java/com/dku/springstudy/domain/common/BaseTimeEntity.java @@ -0,0 +1,25 @@ +package com.dku.springstudy.domain.common; + +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.Column; +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; +import java.time.LocalDateTime; + +@Getter +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public class BaseTimeEntity { + + @CreatedDate + @Column(nullable = false, updatable = false) + protected LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + protected LocalDateTime modifiedAt; +} diff --git a/src/main/java/com/dku/springstudy/domain/constant/Category.java b/src/main/java/com/dku/springstudy/domain/constant/Category.java new file mode 100644 index 0000000..fff7840 --- /dev/null +++ b/src/main/java/com/dku/springstudy/domain/constant/Category.java @@ -0,0 +1,28 @@ +package com.dku.springstudy.domain.constant; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum Category { + + DIGITAL("디지털기기"), LIFE_APPLIANCE("생활가전"), FURNITURE_INTERIOR("가구/인테리어"), CHILD_GOODS("유아용품"), + LIFE_FOOD("생활/가공식품"), CHILD_BOOK("유아도서"), WOMEN_DRESS("여성의류"), MEN_DRESS_ETC("남성패션/잡화"), GAME_HOBBY("게임/취미"), + BEAUTY("뷰티/미용"), ANIMAL("반려동물용품"), BOOK_TICKET_MUSIC("도서/티켓/음반"), ETC("기타중고물품"), CAR("중고차"); + + private final String name; + + // 캐싱해두므로 조회할 때마다 모든 값 순회하지 않는다. + private static final Map BY_NAME = + Stream.of(values()).collect(Collectors.toMap(Category::name, e -> e)); + + public static Category valueOfName(String name) { + return BY_NAME.get(name); + } +} diff --git a/src/main/java/com/dku/springstudy/domain/constant/Role.java b/src/main/java/com/dku/springstudy/domain/constant/Role.java new file mode 100644 index 0000000..7d64e83 --- /dev/null +++ b/src/main/java/com/dku/springstudy/domain/constant/Role.java @@ -0,0 +1,6 @@ +package com.dku.springstudy.domain.constant; + +public enum Role { + + USER, ADMIN; +} diff --git a/src/main/java/com/dku/springstudy/domain/constant/Status.java b/src/main/java/com/dku/springstudy/domain/constant/Status.java new file mode 100644 index 0000000..5b407bd --- /dev/null +++ b/src/main/java/com/dku/springstudy/domain/constant/Status.java @@ -0,0 +1,26 @@ +package com.dku.springstudy.domain.constant; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum Status { + + PROGRESS("판매중"), COMPLETE("거래완료"); + + private final String name; + + // 캐싱해두므로 조할 때마다 모든 값 순회하지 않는다. + private static final Map BY_NAME = + Stream.of(values()).collect(Collectors.toMap(Status::name, e -> e)); + + public static Status valueOfName(String name) { + return BY_NAME.get(name); + } +} diff --git a/src/main/java/com/dku/springstudy/dto/auth/request/LoginRequestDto.java b/src/main/java/com/dku/springstudy/dto/auth/request/LoginRequestDto.java new file mode 100644 index 0000000..af099c0 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/auth/request/LoginRequestDto.java @@ -0,0 +1,26 @@ +package com.dku.springstudy.dto.auth.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class LoginRequestDto { + + @Schema(example = "abc123@gmail.com", description = "이메일(ID)") + @Email(message = "이메일 형식에 맞지 않습니다.") + @NotBlank(message = "이메일을 입력해주세요.") + private String email; + + @Schema(example = "abc123!", description = "비밀번호") + @NotBlank(message = "비밀번호를 입력해주세요.") + @Pattern(regexp="(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}", + message = "비밀번호는 영문 대/소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8자 ~ 20자여야 합니다.") + private String password; +} diff --git a/src/main/java/com/dku/springstudy/dto/auth/request/SignUpRequestDto.java b/src/main/java/com/dku/springstudy/dto/auth/request/SignUpRequestDto.java new file mode 100644 index 0000000..54ecc11 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/auth/request/SignUpRequestDto.java @@ -0,0 +1,41 @@ +package com.dku.springstudy.dto.auth.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class SignUpRequestDto { + + @Schema(example = "abc123@gmail.com", description = "이메일(ID)") + @Email(message = "이메일 형식에 맞지 않습니다.") + @NotBlank(message = "이메일을 입력해주세요.") + private String email; + + @Schema(example = "abc123!", description = "비밀번호") + @NotBlank(message = "비밀번호를 입력해주세요.") + @Pattern(regexp="(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}", + message = "비밀번호는 영문 대/소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8자 ~ 20자여야 합니다.") + private String password; + + @Schema(example = "이수정", description = "이름") + @NotBlank(message = "이름을 입력해주세요.") + private String name; + + @Schema(example = "010-1234-5678", description = "폰번호") + @NotBlank(message = "폰번호를 입력해주세요.") + @Pattern(regexp = "[0-9]{3}-+[0-9]{4}-+[0-9]{4}", message = "폰번호는 010-XXXX-XXXX 형태여야 합니다.") + private String phoneNumber; + + @Schema(example = "sujeong", description = "닉네임") + @NotBlank(message = "닉네임을 입력해주세요.") + @Size(min = 2, message = "닉네임이 너무 짧습니다.") + private String nickname; +} diff --git a/src/main/java/com/dku/springstudy/dto/auth/response/LoginResponseDto.java b/src/main/java/com/dku/springstudy/dto/auth/response/LoginResponseDto.java new file mode 100644 index 0000000..50f95e4 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/auth/response/LoginResponseDto.java @@ -0,0 +1,20 @@ +package com.dku.springstudy.dto.auth.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class LoginResponseDto { + + @Schema(description = "로그인 성공 시 발급되는 토큰") + private String accessToken; + @Schema(description = "로그인 성공 시 발급되는 재발급토큰") + private String refreshToken; + + public static LoginResponseDto of(String accessToken, String refreshToken) { + return new LoginResponseDto(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/dku/springstudy/dto/auth/response/SignUpResponseDto.java b/src/main/java/com/dku/springstudy/dto/auth/response/SignUpResponseDto.java new file mode 100644 index 0000000..3bdacb0 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/auth/response/SignUpResponseDto.java @@ -0,0 +1,18 @@ +package com.dku.springstudy.dto.auth.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class SignUpResponseDto { + + @Schema(example = "12", description = "회원가입이 완료된 사용자의 PK") + private Long id; + + public static SignUpResponseDto of(Long id) { + return new SignUpResponseDto(id); + } +} diff --git a/src/main/java/com/dku/springstudy/dto/common/BaseResponse.java b/src/main/java/com/dku/springstudy/dto/common/BaseResponse.java new file mode 100644 index 0000000..116b9fa --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/common/BaseResponse.java @@ -0,0 +1,11 @@ +package com.dku.springstudy.dto.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class BaseResponse { + + private boolean isSuccess; +} diff --git a/src/main/java/com/dku/springstudy/dto/common/ErrorResponse.java b/src/main/java/com/dku/springstudy/dto/common/ErrorResponse.java new file mode 100644 index 0000000..809fc31 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/common/ErrorResponse.java @@ -0,0 +1,16 @@ +package com.dku.springstudy.dto.common; + +import lombok.Getter; + +@Getter +public class ErrorResponse extends BaseResponse { + + private int errorCode; + private String errorMessage; + + public ErrorResponse(int errorCode, String errorMessage) { + super(false); + this.errorCode = errorCode; + this.errorMessage = errorMessage; + } +} diff --git a/src/main/java/com/dku/springstudy/dto/common/SuccessResponse.java b/src/main/java/com/dku/springstudy/dto/common/SuccessResponse.java new file mode 100644 index 0000000..c44aaf7 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/common/SuccessResponse.java @@ -0,0 +1,14 @@ +package com.dku.springstudy.dto.common; + +import lombok.Getter; + +@Getter +public class SuccessResponse extends BaseResponse { + + private T data; + + public SuccessResponse(T data) { + super(true); + this.data = data; + } +} diff --git a/src/main/java/com/dku/springstudy/dto/common/interceptor/HttpInterceptor.java b/src/main/java/com/dku/springstudy/dto/common/interceptor/HttpInterceptor.java new file mode 100644 index 0000000..bf54f7d --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/common/interceptor/HttpInterceptor.java @@ -0,0 +1,48 @@ +//package com.dku.springstudy.dto.common.interceptor; +// +//import com.dku.springstudy.dto.common.SuccessResponse; +//import com.fasterxml.jackson.databind.ObjectMapper; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.stereotype.Component; +//import org.springframework.web.servlet.HandlerInterceptor; +//import org.springframework.web.util.ContentCachingResponseWrapper; +// +//import javax.servlet.http.HttpServletRequest; +//import javax.servlet.http.HttpServletResponse; +// +//@Slf4j +//@RequiredArgsConstructor +//@Component +//public class HttpInterceptor implements HandlerInterceptor { +// +// private final ObjectMapper objectMapper; +// +// // 사용자에게 response가 나가기 전에 처리 +// @Override +// public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object object, Exception ex) throws Exception { +// +// final ContentCachingResponseWrapper cachingResponse = (ContentCachingResponseWrapper) response; // 시큐리티 필터에서 넘어온 응답 값 +// +// if (!String.valueOf(response.getStatus()).startsWith("2")) { +// return; +// } +// +// if ((cachingResponse.getContentType() != null) && (cachingResponse.getContentType().contains("application/json"))) { // JSON 응답 값만 +// if (cachingResponse.getContentAsByteArray().length != 0) { +// String body = new String(cachingResponse.getContentAsByteArray()); +// +// Object data = objectMapper.readValue(body, Object.class); +// +// SuccessResponse successResponseDto = new SuccessResponse<>(data); +// +// String wrappedBody = objectMapper.writeValueAsString(successResponseDto); +// +// cachingResponse.resetBuffer(); +// +// cachingResponse.getOutputStream().write(wrappedBody.getBytes(), 0, wrappedBody.getBytes().length); +// log.info("SuccessResponse Body : ", wrappedBody); +// } +// } +// } +//} diff --git a/src/main/java/com/dku/springstudy/dto/like/LikeClickResponseDto.java b/src/main/java/com/dku/springstudy/dto/like/LikeClickResponseDto.java new file mode 100644 index 0000000..fb89177 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/like/LikeClickResponseDto.java @@ -0,0 +1,18 @@ +package com.dku.springstudy.dto.like; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class LikeClickResponseDto { + + @Schema(example = "add", description = "좋아요 추가 / 취소 여부") + private String activity; + + public static LikeClickResponseDto of(String activity) { + return new LikeClickResponseDto(activity); + } +} diff --git a/src/main/java/com/dku/springstudy/dto/product/request/ProductCreateRequestDto.java b/src/main/java/com/dku/springstudy/dto/product/request/ProductCreateRequestDto.java new file mode 100644 index 0000000..bc1bd05 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/product/request/ProductCreateRequestDto.java @@ -0,0 +1,31 @@ +package com.dku.springstudy.dto.product.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductCreateRequestDto { + + @Schema(example = "옷 판매", description = "상품 글 제목") + @NotBlank + private String title; + + @Schema(example = "WOMEN_DRESS", description = "상품 카테고리") + @NotNull + private String category; + + @Schema(example = "10000", description = "상품 가격") + @Min(value = 0) + private Integer price; + + @Schema(example = "1년 사용했습니다.", description = "상품 글 내용") + @NotBlank + private String content; +} diff --git a/src/main/java/com/dku/springstudy/dto/product/response/ProductCreateResponseDto.java b/src/main/java/com/dku/springstudy/dto/product/response/ProductCreateResponseDto.java new file mode 100644 index 0000000..80e58e6 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/product/response/ProductCreateResponseDto.java @@ -0,0 +1,33 @@ +package com.dku.springstudy.dto.product.response; + +import com.dku.springstudy.domain.constant.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class ProductCreateResponseDto { + + @Schema(example = "https://d-coding.s3.ap-northeast-2.amazonaws.com/67a29351-9339-42b0-ba46-da6584ab93cc.jpg", description = "업로드한 사진들의 url") + private List fileUrls; + + @Schema(example = "옷 판매", description = "상품 글 제목") + private String title; + + @Schema(example = "WOMEN_DRESS", description = "상품 카테고리") + private Category category; + + @Schema(example = "10000", description = "상품 가격") + private Integer price; + + @Schema(example = "1년 사용했습니다.", description = "상품 글 내용") + private String content; + + public static ProductCreateResponseDto of(List fileUrls, String title, Category category, Integer price, String content) { + return new ProductCreateResponseDto(fileUrls, title, category, price, content); + } +} diff --git a/src/main/java/com/dku/springstudy/dto/product/response/ProductInfoResponseDto.java b/src/main/java/com/dku/springstudy/dto/product/response/ProductInfoResponseDto.java new file mode 100644 index 0000000..a56a3bc --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/product/response/ProductInfoResponseDto.java @@ -0,0 +1,119 @@ +package com.dku.springstudy.dto.product.response; + +import com.dku.springstudy.domain.Product; +import com.dku.springstudy.domain.User; +import com.dku.springstudy.domain.constant.Category; +import com.dku.springstudy.domain.constant.Status; +import com.dku.springstudy.s3.domain.File; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class ProductInfoResponseDto { + + @Schema(example = "1", description = "상품 아이디") + private Long productId; + + @Schema(example = "자전거 판매", description = "상품 글 제목") + private String title; + + @Schema(example = "자전거 판매합니다.", description = "상품 글 내용") + private String content; + + @Schema(example = "100000", description = "상품 가격") + private Integer price; + + @Schema(example = "EXERCISE", description = "상품 카테고리") + private Category category; + + @Schema(example = "2", description = "상품 좋아요 개수") + private Integer likeCount; + + @Schema(example = "PROGRESS", description = "상품 상태") + private Status status; + + @Schema(description = "상품 사진") + private List imageUrls; + + @Schema(description = "상품 판매자") + private userInfoDto writer; + + @Schema(description = "판매자의 판매 중인 상품") + private List itemList; + + @Schema(example = "true", description = "판매자 여부") + private boolean isWriter; + + @Schema(example = "2023-02-11 01:01:01", description = "상품 글 수정시간") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updatedAt; + + @Schema(description = "판매자 정보") + @Getter + public static class userInfoDto { + + @Schema(example = "1", description = "판매자 아이디(PK)") + private Long userId; + + @Schema(example = "jjeong", description = "판매자 닉네임") + private String nickname; + + @Schema(description = "판매자 프로필 사진") + private String imgUrl; + + public userInfoDto(User user) { + this.userId = user.getId(); + this.nickname = user.getNickname(); + this.imgUrl = user.getImgUrl(); + } + } + + @Schema(description = "판매자가 판매 중인 상품 정보") + @Getter + public static class SubItemInfoDto { + + @Schema(example = "1", description = "상품 아이디") + private Long productId; + + @Schema(description = "") + private String imageUrl; + + @Schema(example = "선풍기 판매", description = "상품 글 제목") + private String title; + + @Schema(example = "50000", description = "상품 글 가격") + private Integer price; + + public SubItemInfoDto(Product product) { + this.productId = product.getId(); + this.imageUrl = product.getImageUrls().get(0).getUrl(); + this.title = product.getTitle(); + this.price = product.getPrice(); + } + } + + public static ProductInfoResponseDto from(User user, Product product) { + return new ProductInfoResponseDto( + product.getId(), + product.getTitle(), + product.getContent(), + product.getPrice(), + product.getCategory(), + product.getLikeCount(), + product.getStatus(), + product.getImageUrls().stream().map(File::getUrl).collect(Collectors.toUnmodifiableList()), + new userInfoDto(product.getUser()), + product.getUser().getProductList().stream().map(SubItemInfoDto::new).collect(Collectors.toUnmodifiableList()), + product.getUser().getId().equals(user.getId()), + product.getModifiedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/dku/springstudy/dto/user/response/UserUpdateResponseDto.java b/src/main/java/com/dku/springstudy/dto/user/response/UserUpdateResponseDto.java new file mode 100644 index 0000000..f5d23ed --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/user/response/UserUpdateResponseDto.java @@ -0,0 +1,24 @@ +package com.dku.springstudy.dto.user.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import javax.validation.constraints.NotBlank; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class UserUpdateResponseDto { + + @Schema(example = "https://d-coding.s3.ap-northeast-2.amazonaws.com/67a29351-9339-42b0-ba46-da6584ab93cc.jpg", description = "업로드한 사진 url") + private String imgUrl; + + @Schema(example = "sujjeong", description = "닉네임") + @NotBlank + private String nickname; + + public static UserUpdateResponseDto of(String imgUrl, String nickname) { + return new UserUpdateResponseDto(imgUrl, nickname); + } +} diff --git a/src/main/java/com/dku/springstudy/exception/CustomException.java b/src/main/java/com/dku/springstudy/exception/CustomException.java new file mode 100644 index 0000000..2c7713a --- /dev/null +++ b/src/main/java/com/dku/springstudy/exception/CustomException.java @@ -0,0 +1,11 @@ +package com.dku.springstudy.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CustomException extends RuntimeException { + + ErrorCode errorCode; +} diff --git a/src/main/java/com/dku/springstudy/exception/ErrorCode.java b/src/main/java/com/dku/springstudy/exception/ErrorCode.java new file mode 100644 index 0000000..43483be --- /dev/null +++ b/src/main/java/com/dku/springstudy/exception/ErrorCode.java @@ -0,0 +1,32 @@ +package com.dku.springstudy.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.apache.http.HttpException; +import org.springframework.http.HttpStatus; + + +@AllArgsConstructor +@Getter +public enum ErrorCode { + + // user + USER_ID_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 아이디(PK)를 가진 회원을 찾을 수 없습니다."), + USER_EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 이메일을 가진 회원을 찾을 수 없습니다."), + USER_EMAIL_Duplication(HttpStatus.CONFLICT, "이미 가입된 이메일입니다."), + USER_PASSWORD_NOT_MATCHES(HttpStatus.UNAUTHORIZED, "비밀번호가 틀렸습니다."), + + // product + FILE_EXTENSION_NOT_SUPPORT(HttpStatus.BAD_REQUEST, "지원하지 않는 파일 확장자입니다."), + FILE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), + PRODUCT_ID_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 아이디(PK)를 가진 상품을 찾을 수 없습니다."), + ; + + private final HttpStatus httpstatus; + private final String message; + + ErrorCode(HttpException e) { + this.httpstatus = HttpStatus.INTERNAL_SERVER_ERROR; + this.message = e.getMessage(); + } +} diff --git a/src/main/java/com/dku/springstudy/exception/GlobalExceptionHandler.java b/src/main/java/com/dku/springstudy/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..7f9e386 --- /dev/null +++ b/src/main/java/com/dku/springstudy/exception/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package com.dku.springstudy.exception; + +import com.dku.springstudy.dto.common.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + protected ResponseEntity customExceptionHandle(CustomException e) { + log.error("ExceptionHandler throw CustomException : {}", e.getErrorCode()); + + HttpStatus status = e.getErrorCode().getHttpstatus(); + + return ResponseEntity + .status(status) + .body(new ErrorResponse(status.value(), e.getErrorCode().getMessage())); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity exceptionHandle(Exception e){ + log.error("ExceptionHandler throw Exception : {}", e.getMessage()); + + HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; + + return ResponseEntity + .status(status) + .body(new ErrorResponse(status.value(), e.getMessage())); + } +} diff --git a/src/main/java/com/dku/springstudy/repository/LikeRepository.java b/src/main/java/com/dku/springstudy/repository/LikeRepository.java new file mode 100644 index 0000000..f7ee5a9 --- /dev/null +++ b/src/main/java/com/dku/springstudy/repository/LikeRepository.java @@ -0,0 +1,13 @@ +package com.dku.springstudy.repository; + +import com.dku.springstudy.domain.Likes; +import com.dku.springstudy.domain.Product; +import com.dku.springstudy.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeRepository extends JpaRepository { + + Optional findByUserAndProduct(User user, Product product); +} diff --git a/src/main/java/com/dku/springstudy/repository/ProductRepository.java b/src/main/java/com/dku/springstudy/repository/ProductRepository.java new file mode 100644 index 0000000..dadc85f --- /dev/null +++ b/src/main/java/com/dku/springstudy/repository/ProductRepository.java @@ -0,0 +1,7 @@ +package com.dku.springstudy.repository; + +import com.dku.springstudy.domain.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRepository extends JpaRepository { +} diff --git a/src/main/java/com/dku/springstudy/repository/UserRepository.java b/src/main/java/com/dku/springstudy/repository/UserRepository.java new file mode 100644 index 0000000..28eea54 --- /dev/null +++ b/src/main/java/com/dku/springstudy/repository/UserRepository.java @@ -0,0 +1,12 @@ +package com.dku.springstudy.repository; + +import com.dku.springstudy.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + boolean existsByEmail(String email); + Optional findByEmail(String nickname); +} diff --git a/src/main/java/com/dku/springstudy/s3/S3Config.java b/src/main/java/com/dku/springstudy/s3/S3Config.java new file mode 100644 index 0000000..af348ab --- /dev/null +++ b/src/main/java/com/dku/springstudy/s3/S3Config.java @@ -0,0 +1,34 @@ +package com.dku.springstudy.s3; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } +} diff --git a/src/main/java/com/dku/springstudy/s3/domain/File.java b/src/main/java/com/dku/springstudy/s3/domain/File.java new file mode 100644 index 0000000..792658a --- /dev/null +++ b/src/main/java/com/dku/springstudy/s3/domain/File.java @@ -0,0 +1,39 @@ +package com.dku.springstudy.s3.domain; + +import com.dku.springstudy.domain.Product; +import com.dku.springstudy.domain.common.BaseTimeEntity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +public class File extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "file_id") + private Long id; + + @JoinColumn(name = "product_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) +// @OnDelete(action = OnDeleteAction.CASCADE) + private Product product; + + @Column(nullable = false) + private String url; + + @Column(nullable = false) + private String fileName; + + @Builder + private File(Product product, String url, String fileName) { + this.product = product; + this.url = url; + this.fileName = fileName; + } +} diff --git a/src/main/java/com/dku/springstudy/s3/repository/FileRepository.java b/src/main/java/com/dku/springstudy/s3/repository/FileRepository.java new file mode 100644 index 0000000..f3f439f --- /dev/null +++ b/src/main/java/com/dku/springstudy/s3/repository/FileRepository.java @@ -0,0 +1,10 @@ +package com.dku.springstudy.s3.repository; + +import com.dku.springstudy.s3.domain.File; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface FileRepository extends JpaRepository { + List findByProduct_Id(Long productId); +} diff --git a/src/main/java/com/dku/springstudy/s3/service/S3Service.java b/src/main/java/com/dku/springstudy/s3/service/S3Service.java new file mode 100644 index 0000000..38ac427 --- /dev/null +++ b/src/main/java/com/dku/springstudy/s3/service/S3Service.java @@ -0,0 +1,122 @@ +package com.dku.springstudy.s3.service; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.dku.springstudy.domain.Product; +import com.dku.springstudy.exception.CustomException; +import com.dku.springstudy.exception.ErrorCode; +import com.dku.springstudy.s3.domain.File; +import com.dku.springstudy.s3.repository.FileRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class S3Service { + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.region.static}") + private String region; + + private final AmazonS3 amazonS3; + private final FileRepository fileRepository; + + // 파일 업로드 (여러 개) + @Transactional + public List uploadFiles(List multipartFile, Product product) { + List fileUrlList = new ArrayList<>(); + + multipartFile.forEach(file -> { + String fileName = createFileName(file.getOriginalFilename()); + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(file.getSize()); + objectMetadata.setContentType(file.getContentType()); + + try (InputStream inputStream = file.getInputStream()) { + amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + } catch (IOException e) { + throw new CustomException(ErrorCode.FILE_UPLOAD_FAIL); + } + + File saveFile = File.builder() + .product(product) + .url(getUrl(fileName)) + .fileName(fileName) + .build(); + + fileRepository.save(saveFile); + fileUrlList.add(getUrl(fileName)); + }); + return fileUrlList; + } + + // 파일 업로드 (1개) + @Transactional + public String uploadFile(MultipartFile file) { + + String fileName = createFileName(file.getOriginalFilename()); + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(file.getSize()); + objectMetadata.setContentType(file.getContentType()); + + try (InputStream inputStream = file.getInputStream()) { + amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + } catch (IOException e) { + throw new CustomException(ErrorCode.FILE_UPLOAD_FAIL); + } + + return getUrl(fileName); + } + + private String createFileName(String fileName) { + return UUID.randomUUID().toString().concat(getFileExtension(fileName)); + } + + private String getFileExtension(String fileName) { + List extensions = Arrays.asList(".jpg", ".jpeg", ".png"); + String fileExtension = fileName.substring(fileName.lastIndexOf(".")); + + if (!fileName.contains(fileExtension)) { + throw new CustomException(ErrorCode.FILE_EXTENSION_NOT_SUPPORT); + } + + return fileName.substring(fileName.lastIndexOf(".")); + } + + private String getUrl(String fileName) { + return amazonS3.getUrl(bucket, fileName).toString(); + } + + // 파일 삭제 (1개) + public void deleteFile(String fileUrl) { + + String fileKey = fileUrl.substring(49); + final AmazonS3 s3 = AmazonS3ClientBuilder.standard().withRegion(region).build(); + + try { + s3.deleteObject(bucket, fileKey); + } catch (AmazonServiceException e) { + System.err.println(e.getErrorMessage()); + System.exit(1); + } + } +} diff --git a/src/main/java/com/dku/springstudy/security/CustomUserDetails.java b/src/main/java/com/dku/springstudy/security/CustomUserDetails.java new file mode 100644 index 0000000..76cb5ef --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/CustomUserDetails.java @@ -0,0 +1,61 @@ +package com.dku.springstudy.security; + +import com.dku.springstudy.domain.User; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Arrays; +import java.util.Collection; + +@Getter +public class CustomUserDetails implements UserDetails { + + private Long id; + private String email; + @JsonIgnore + private String password; + private String nickname; + private String authority; + + public CustomUserDetails(User user) { + this.id = user.getId(); + this.email = user.getEmail(); + this.password = user.getPassword(); + this.nickname = user.getNickname(); + this.authority = user.getRole().name(); + } + + @Override + public Collection getAuthorities() { + // 만약, 여러 개의 권한을 부여해 관리해야 한다면 List 형태로 반환해야 한다. + return Arrays.asList(new SimpleGrantedAuthority(authority)); + } + + @Override + public String getUsername() { + return nickname; + } + + @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/dku/springstudy/security/CustomUserDetailsService.java b/src/main/java/com/dku/springstudy/security/CustomUserDetailsService.java new file mode 100644 index 0000000..8e010f8 --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/CustomUserDetailsService.java @@ -0,0 +1,23 @@ +package com.dku.springstudy.security; + +import com.dku.springstudy.domain.User; +import com.dku.springstudy.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 email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("해당 이메일을 가진 유저를 찾을 수 없습니다.")); + return new CustomUserDetails(user); + } +} diff --git a/src/main/java/com/dku/springstudy/security/exception/JwtAccessDeniedHandler.java b/src/main/java/com/dku/springstudy/security/exception/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..1c629bb --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/exception/JwtAccessDeniedHandler.java @@ -0,0 +1,19 @@ +package com.dku.springstudy.security.exception; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_FORBIDDEN); // 403 에러 + } +} diff --git a/src/main/java/com/dku/springstudy/security/exception/JwtAuthenticationEntryPoint.java b/src/main/java/com/dku/springstudy/security/exception/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..0e32682 --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/exception/JwtAuthenticationEntryPoint.java @@ -0,0 +1,19 @@ +package com.dku.springstudy.security.exception; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); // 401 에러 + } +} diff --git a/src/main/java/com/dku/springstudy/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/dku/springstudy/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b3a8d62 --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package com.dku.springstudy.security.jwt; + +import com.dku.springstudy.dto.common.ErrorResponse; +import com.dku.springstudy.exception.CustomException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.GenericFilterBean; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends GenericFilterBean { + + private final JwtTokenProvider jwtTokenProvider; + + public static final String AUTHORIZATION_HEADER = "Authorization"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + String token = resolveToken((HttpServletRequest) request); + + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { // 토큰이 유효하다면 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); // 사용자 정보를 SecurityContext에 저장 + } else { + log.debug("유효한 JWT 토큰이 없습니다, uri: {}", ((HttpServletRequest) request).getRequestURI()); + } + + chain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); // Header에서 token을 가져온다. + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); // Bearer를 제외시킨 값 + } + + return null; + } +} diff --git a/src/main/java/com/dku/springstudy/security/jwt/JwtTokenProvider.java b/src/main/java/com/dku/springstudy/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..cfaaa5a --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/jwt/JwtTokenProvider.java @@ -0,0 +1,95 @@ +package com.dku.springstudy.security.jwt; + +import com.dku.springstudy.security.CustomUserDetailsService; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SecurityException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final CustomUserDetailsService customUserDetailsService; + + @Value("${jwt.secret-key}") + private String secretKey; + + private final Long accessTokenValidMilliSecond = 24 * 60 * 60 * 1000L; // 1일 + private final Long refreshTokenValidMilliSecond = 30 * 24 * 60 * 60 * 1000L; // 30일 + + // secretKey 값을 BASE64로 decode해 key 변수에 할당 + private Key getSecretKey(String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } + + // 토큰 발급 + public String createToken(Claims claims, long expiredDuration) { + Date now = new Date(); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setClaims(claims) + .signWith(getSecretKey(secretKey), SignatureAlgorithm.HS256) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + expiredDuration)) + .compact(); + } + + // 로그인 성공 시 access token 발급 + public String createLoginAccessToken(String userEmail, String role) { + Claims claims = Jwts.claims(); + claims.setSubject(userEmail); + claims.put("role", role); + + return createToken(claims, accessTokenValidMilliSecond); + } + + // 로그인 성공 시 refresh token 발급 + public String createLoginRefreshToken(String userEmail) { + Claims claims = Jwts.claims(); + claims.setSubject(userEmail); + + return createToken(claims, refreshTokenValidMilliSecond); + } + + // 토큰에서 인증 정보 추출 + public Authentication getAuthentication(String token) { + UserDetails userDetails = customUserDetailsService.loadUserByUsername(getEmail(token)); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + // 토큰에서 Email 추출 + public String getEmail(String token) { + return Jwts.parserBuilder().setSigningKey(getSecretKey(secretKey)).build().parseClaimsJws(token).getBody().getSubject(); + } + + // 토큰 검증 + public boolean validateToken(String token) { + try { + Jws claimsJws = Jwts.parserBuilder().setSigningKey(getSecretKey(secretKey)).build().parseClaimsJws(token); + return !claimsJws.getBody().getExpiration().before(new Date()); + } catch (ExpiredJwtException e) { + log.info("만료된 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.info("지원되지 않는 토큰입니다."); + } catch (IllegalArgumentException e) { + log.info("JWT 토큰이 잘못되었습니다."); + } catch (SecurityException | MalformedJwtException e) { + log.info("잘못된 JWT 서명입니다."); + } + return false; + } +} diff --git a/src/main/java/com/dku/springstudy/service/AuthService.java b/src/main/java/com/dku/springstudy/service/AuthService.java new file mode 100644 index 0000000..45d3f92 --- /dev/null +++ b/src/main/java/com/dku/springstudy/service/AuthService.java @@ -0,0 +1,58 @@ +package com.dku.springstudy.service; + +import com.dku.springstudy.domain.User; +import com.dku.springstudy.dto.auth.request.LoginRequestDto; +import com.dku.springstudy.dto.auth.request.SignUpRequestDto; +import com.dku.springstudy.dto.auth.response.LoginResponseDto; +import com.dku.springstudy.dto.auth.response.SignUpResponseDto; +import com.dku.springstudy.exception.CustomException; +import com.dku.springstudy.exception.ErrorCode; +import com.dku.springstudy.repository.UserRepository; +import com.dku.springstudy.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + @Transactional + public SignUpResponseDto signUp(SignUpRequestDto signUpRequestDto) { + + if (userRepository.existsByEmail(signUpRequestDto.getEmail())) { + throw new CustomException(ErrorCode.USER_EMAIL_Duplication); + } + + User user = User.builder() + .email(signUpRequestDto.getEmail()) + .name(signUpRequestDto.getName()) + .password(passwordEncoder.encode(signUpRequestDto.getPassword())) + .phoneNumber(signUpRequestDto.getPhoneNumber()) + .nickname(signUpRequestDto.getNickname()) + .build(); + + Long id = userRepository.save(user).getId(); + + return SignUpResponseDto.of(id); + } + + public LoginResponseDto login(LoginRequestDto loginRequestDto) { + User user = userRepository.findByEmail(loginRequestDto.getEmail()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_EMAIL_NOT_FOUND)); + + if(passwordEncoder.matches(loginRequestDto.getPassword(), user.getPassword())) { + String loginAccessToken = jwtTokenProvider.createLoginAccessToken(user.getEmail(), user.getRole().name()); + String loginRefreshToken = jwtTokenProvider.createLoginRefreshToken(user.getEmail()); + return LoginResponseDto.of(loginAccessToken, loginRefreshToken); + } else { + throw new CustomException(ErrorCode.USER_PASSWORD_NOT_MATCHES); + } + } +} diff --git a/src/main/java/com/dku/springstudy/service/LikeService.java b/src/main/java/com/dku/springstudy/service/LikeService.java new file mode 100644 index 0000000..464c1c3 --- /dev/null +++ b/src/main/java/com/dku/springstudy/service/LikeService.java @@ -0,0 +1,51 @@ +package com.dku.springstudy.service; + +import com.dku.springstudy.domain.Likes; +import com.dku.springstudy.domain.Product; +import com.dku.springstudy.domain.User; +import com.dku.springstudy.dto.like.LikeClickResponseDto; +import com.dku.springstudy.exception.CustomException; +import com.dku.springstudy.exception.ErrorCode; +import com.dku.springstudy.repository.LikeRepository; +import com.dku.springstudy.repository.ProductRepository; +import com.dku.springstudy.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class LikeService { + + private final LikeRepository likeRepository; + private final UserRepository userRepository; + private final ProductRepository productRepository; + + @Transactional + public LikeClickResponseDto clickLikeBtn(Long memberId, Long productId) { + User user = userRepository.findById(memberId).orElseThrow(() -> new CustomException(ErrorCode.USER_ID_NOT_FOUND)); + Product product = productRepository.findById(productId).orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_ID_NOT_FOUND)); + + return checkActivity(user, product); + } + + private LikeClickResponseDto checkActivity(User user, Product product) { + Optional likeByUserAndProduct = likeRepository.findByUserAndProduct(user, product); + + if (likeByUserAndProduct.isPresent()) { // 좋아요를 누른적이 있다면 취소 + Likes like = likeByUserAndProduct.get(); + likeRepository.delete(like); + return LikeClickResponseDto.of("cancel"); + } else { + Likes like = Likes.builder() + .user(user) + .product(product) + .build(); + likeRepository.save(like); + return LikeClickResponseDto.of("add"); + } + } +} diff --git a/src/main/java/com/dku/springstudy/service/ProductService.java b/src/main/java/com/dku/springstudy/service/ProductService.java new file mode 100644 index 0000000..a8ec865 --- /dev/null +++ b/src/main/java/com/dku/springstudy/service/ProductService.java @@ -0,0 +1,66 @@ +package com.dku.springstudy.service; + +import com.dku.springstudy.domain.Product; +import com.dku.springstudy.domain.User; +import com.dku.springstudy.domain.constant.Category; +import com.dku.springstudy.dto.product.request.ProductCreateRequestDto; +import com.dku.springstudy.dto.product.response.ProductCreateResponseDto; +import com.dku.springstudy.dto.product.response.ProductInfoResponseDto; +import com.dku.springstudy.exception.CustomException; +import com.dku.springstudy.exception.ErrorCode; +import com.dku.springstudy.repository.ProductRepository; +import com.dku.springstudy.repository.UserRepository; +import com.dku.springstudy.s3.repository.FileRepository; +import com.dku.springstudy.s3.service.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class ProductService { + + private final FileRepository fileRepository; + private final ProductRepository productRepository; + private final UserRepository userRepository; + private final S3Service s3Service; + + /** + * 글 등록에 필요한 데이터를 적절히 입력받아 Product 테이블에 저장, 사진들을 S3에 업로드하고 File 테이블에 저장한다. + * + * @param dto 글 등록에 필요한 정보 + * @param file 업로드할 사진들 + * @param loginMemberId 로그인한 사용자의 아이디(PK) + * @return CreateResponseDto + */ + @Transactional + public ProductCreateResponseDto createPost(ProductCreateRequestDto dto, List file, Long loginMemberId) { + User user = userRepository.findById(loginMemberId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_ID_NOT_FOUND)); + + Product product = Product.builder() + .user(user) + .title(dto.getTitle()) + .content(dto.getContent()) + .price(dto.getPrice()) + .category(Category.valueOf(dto.getCategory())) + .build(); + + productRepository.save(product); + + List fileUrls = s3Service.uploadFiles(file, product); // S3에 이미지 업로드 + + return ProductCreateResponseDto.of(fileUrls, product.getTitle(), product.getCategory(), product.getPrice(), product.getContent()); + } + + public ProductInfoResponseDto getProductInfo(Long loginMemberId, Long productId) { + + User user = userRepository.findById(loginMemberId).orElseThrow(() -> new CustomException(ErrorCode.USER_ID_NOT_FOUND)); + Product product = productRepository.findById(productId).orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_ID_NOT_FOUND)); + return ProductInfoResponseDto.from(user, product); + } +} diff --git a/src/main/java/com/dku/springstudy/service/UserService.java b/src/main/java/com/dku/springstudy/service/UserService.java new file mode 100644 index 0000000..749ffe2 --- /dev/null +++ b/src/main/java/com/dku/springstudy/service/UserService.java @@ -0,0 +1,47 @@ +package com.dku.springstudy.service; + +import com.dku.springstudy.domain.User; +import com.dku.springstudy.dto.user.response.UserUpdateResponseDto; +import com.dku.springstudy.exception.CustomException; +import com.dku.springstudy.exception.ErrorCode; +import com.dku.springstudy.s3.service.S3Service; +import com.dku.springstudy.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class UserService { + + private final UserRepository userRepository; + + private final S3Service s3Service; + + @Transactional + public UserUpdateResponseDto update(Long loginMemberId, String nickname, MultipartFile file) { + + User user = userRepository.findById(loginMemberId).orElseThrow(() -> new CustomException(ErrorCode.USER_ID_NOT_FOUND)); + String imgUrl = null; + + if(!file.isEmpty()) { + imgUrl = s3Service.uploadFile(file); + } + + user.update(nickname, imgUrl); + + return UserUpdateResponseDto.of(nickname, imgUrl); + } + + @Transactional + public UserUpdateResponseDto deleteImg(Long loginMemberId) { + + User user = userRepository.findById(loginMemberId).orElseThrow(() -> new CustomException(ErrorCode.USER_ID_NOT_FOUND)); + s3Service.deleteFile(user.getImgUrl()); + user.deleteImg(); + + return UserUpdateResponseDto.of(user.getNickname(), null); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -