From 5123667b5af662739c6308bb2dddf352cfcee3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Thu, 25 Jul 2024 17:56:23 +0900 Subject: [PATCH 01/80] =?UTF-8?q?init:=204=EC=A3=BC=EC=B0=A8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + build.gradle | 8 + src/main/java/gift/Application.java | 7 +- .../annotation/LoginMember.java | 12 + .../AuthenticationExceptionHandlerFilter.java | 32 +++ .../filter/AuthenticationFilter.java | 51 ++++ .../authentication/token/JwtProvider.java | 39 +++ .../authentication/token/JwtResolver.java | 34 +++ .../java/gift/authentication/token/Token.java | 21 ++ .../authentication/token/TokenContext.java | 20 ++ src/main/java/gift/config/WebConfig.java | 46 +++ .../gift/converter/StringToUrlConverter.java | 20 ++ src/main/java/gift/domain/Category.java | 99 +++++++ src/main/java/gift/domain/Member.java | 82 ++++++ src/main/java/gift/domain/Product.java | 134 +++++++++ src/main/java/gift/domain/ProductOption.java | 90 ++++++ src/main/java/gift/domain/WishProduct.java | 88 ++++++ .../java/gift/domain/base/BaseEntity.java | 28 ++ .../java/gift/domain/base/BaseTimeEntity.java | 54 ++++ src/main/java/gift/domain/vo/Color.java | 49 ++++ src/main/java/gift/domain/vo/Email.java | 44 +++ src/main/java/gift/domain/vo/Password.java | 48 ++++ .../gift/repository/CategoryRepository.java | 10 + .../gift/repository/MemberIdAuditorAware.java | 15 + .../gift/repository/MemberRepository.java | 14 + .../repository/ProductOptionRepository.java | 31 ++ .../gift/repository/ProductRepository.java | 14 + .../repository/WishProductRepository.java | 20 ++ .../java/gift/service/CategoryService.java | 57 ++++ .../gift/service/MemberDetailsService.java | 22 ++ src/main/java/gift/service/MemberService.java | 64 +++++ .../gift/service/ProductOptionService.java | 146 ++++++++++ .../java/gift/service/ProductService.java | 99 +++++++ .../java/gift/service/WishProductService.java | 91 ++++++ src/main/java/gift/utils/JsonUtils.java | 27 ++ src/main/java/gift/utils/StringUtils.java | 34 +++ .../controller/api/CategoryApiController.java | 68 +++++ .../controller/api/MemberApiController.java | 77 +++++ .../controller/api/ProductApiController.java | 128 +++++++++ .../view/ProductViewController.java | 44 +++ src/main/java/gift/web/dto/MemberDetails.java | 27 ++ .../gift/web/dto/form/CreateProductForm.java | 23 ++ .../gift/web/dto/form/UpdateProductForm.java | 40 +++ .../gift/web/dto/request/LoginRequest.java | 23 ++ .../category/CreateCategoryRequest.java | 60 ++++ .../category/UpdateCategoryRequest.java | 61 ++++ .../request/member/CreateMemberRequest.java | 42 +++ .../request/product/CreateProductRequest.java | 81 ++++++ .../request/product/UpdateProductRequest.java | 50 ++++ .../CreateProductOptionRequest.java | 47 ++++ .../SubtractProductOptionQuantityRequest.java | 14 + .../UpdateProductOptionRequest.java | 39 +++ .../wishproduct/CreateWishProductRequest.java | 25 ++ .../wishproduct/UpdateWishProductRequest.java | 20 ++ .../gift/web/dto/response/ErrorResponse.java | 49 ++++ .../gift/web/dto/response/LoginResponse.java | 19 ++ .../category/CreateCategoryResponse.java | 47 ++++ .../category/ReadAllCategoriesResponse.java | 16 ++ .../category/ReadCategoryResponse.java | 47 ++++ .../category/UpdateCategoryResponse.java | 47 ++++ .../response/member/CreateMemberResponse.java | 34 +++ .../response/member/ReadMemberResponse.java | 39 +++ .../product/CreateProductResponse.java | 52 ++++ .../product/ReadAllProductsResponse.java | 20 ++ .../response/product/ReadProductResponse.java | 47 ++++ .../product/UpdateProductResponse.java | 47 ++++ .../CreateProductOptionResponse.java | 37 +++ .../ReadAllProductOptionsResponse.java | 20 ++ .../ReadProductOptionResponse.java | 34 +++ ...SubtractProductOptionQuantityResponse.java | 34 +++ .../UpdateProductOptionResponse.java | 33 +++ .../CreateWishProductResponse.java | 27 ++ .../ReadAllWishProductsResponse.java | 19 ++ .../wishproduct/ReadWishProductResponse.java | 75 +++++ .../UpdateWishProductResponse.java | 53 ++++ .../resolver/LoginMemberArgumentResolver.java | 53 ++++ .../web/validation/constraints/HexColor.java | 22 ++ .../web/validation/constraints/Password.java | 22 ++ .../constraints/RequiredKakaoApproval.java | 22 ++ .../constraints/SpecialCharacter.java | 24 ++ .../validation/exception/CustomException.java | 32 +++ .../client/AlreadyExistsException.java | 28 ++ .../exception/client/BadRequestException.java | 32 +++ .../client/IncorrectEmailException.java | 29 ++ .../client/IncorrectPasswordException.java | 33 +++ .../client/InvalidCredentialsException.java | 33 +++ .../client/ResourceNotFoundException.java | 28 ++ .../validation/exception/code/Category.java | 20 ++ .../exception/code/ErrorStatus.java | 50 ++++ .../server/InternalServerException.java | 33 +++ .../handler/ApiExceptionHandler.java | 53 ++++ .../validator/HexColorValidator.java | 23 ++ .../validator/KakaoApprovalValidator.java | 22 ++ .../validator/PasswordValidator.java | 38 +++ .../validator/SpecialCharacterValidator.java | 30 ++ src/main/resources/application-local.yml | 18 ++ src/main/resources/application-secret.yml | 8 + src/main/resources/application.yml | 19 ++ src/main/resources/sql/data.sql | 154 ++++++++++ src/main/resources/sql/schema.sql | 25 ++ src/main/resources/static/index.html | 16 ++ src/main/resources/static/js/script.js | 86 ++++++ src/main/resources/templates/admin.html | 44 +++ .../templates/form/add-product-form.html | 29 ++ .../templates/form/edit-product-form.html | 29 ++ src/test/java/gift/domain/ProductTest.java | 45 +++ .../gift/service/CategoryServiceTest.java | 155 ++++++++++ .../java/gift/service/MemberServiceTest.java | 115 ++++++++ .../service/ProductOptionServiceTest.java | 179 ++++++++++++ .../java/gift/service/ProductServiceTest.java | 264 ++++++++++++++++++ .../gift/service/WishProductServiceTest.java | 173 ++++++++++++ .../gift/utils/CategoryDummyDataProvider.java | 51 ++++ src/test/java/gift/utils/DatabaseCleanup.java | 41 +++ .../gift/utils/MemberDummyDataProvider.java | 48 ++++ .../gift/utils/ProductDummyDataProvider.java | 50 ++++ src/test/java/gift/utils/StringUtilsTest.java | 71 +++++ .../utils/WishProductDummyDataProvider.java | 52 ++++ .../api/MemberApiControllerTest.java | 226 +++++++++++++++ src/test/resources/application-test.yml | 28 ++ 119 files changed, 5870 insertions(+), 1 deletion(-) create mode 100644 src/main/java/gift/authentication/annotation/LoginMember.java create mode 100644 src/main/java/gift/authentication/filter/AuthenticationExceptionHandlerFilter.java create mode 100644 src/main/java/gift/authentication/filter/AuthenticationFilter.java create mode 100644 src/main/java/gift/authentication/token/JwtProvider.java create mode 100644 src/main/java/gift/authentication/token/JwtResolver.java create mode 100644 src/main/java/gift/authentication/token/Token.java create mode 100644 src/main/java/gift/authentication/token/TokenContext.java create mode 100644 src/main/java/gift/config/WebConfig.java create mode 100644 src/main/java/gift/converter/StringToUrlConverter.java create mode 100644 src/main/java/gift/domain/Category.java create mode 100644 src/main/java/gift/domain/Member.java create mode 100644 src/main/java/gift/domain/Product.java create mode 100644 src/main/java/gift/domain/ProductOption.java create mode 100644 src/main/java/gift/domain/WishProduct.java create mode 100644 src/main/java/gift/domain/base/BaseEntity.java create mode 100644 src/main/java/gift/domain/base/BaseTimeEntity.java create mode 100644 src/main/java/gift/domain/vo/Color.java create mode 100644 src/main/java/gift/domain/vo/Email.java create mode 100644 src/main/java/gift/domain/vo/Password.java create mode 100644 src/main/java/gift/repository/CategoryRepository.java create mode 100644 src/main/java/gift/repository/MemberIdAuditorAware.java create mode 100644 src/main/java/gift/repository/MemberRepository.java create mode 100644 src/main/java/gift/repository/ProductOptionRepository.java create mode 100644 src/main/java/gift/repository/ProductRepository.java create mode 100644 src/main/java/gift/repository/WishProductRepository.java create mode 100644 src/main/java/gift/service/CategoryService.java create mode 100644 src/main/java/gift/service/MemberDetailsService.java create mode 100644 src/main/java/gift/service/MemberService.java create mode 100644 src/main/java/gift/service/ProductOptionService.java create mode 100644 src/main/java/gift/service/ProductService.java create mode 100644 src/main/java/gift/service/WishProductService.java create mode 100644 src/main/java/gift/utils/JsonUtils.java create mode 100644 src/main/java/gift/utils/StringUtils.java create mode 100644 src/main/java/gift/web/controller/api/CategoryApiController.java create mode 100644 src/main/java/gift/web/controller/api/MemberApiController.java create mode 100644 src/main/java/gift/web/controller/api/ProductApiController.java create mode 100644 src/main/java/gift/web/controller/view/ProductViewController.java create mode 100644 src/main/java/gift/web/dto/MemberDetails.java create mode 100644 src/main/java/gift/web/dto/form/CreateProductForm.java create mode 100644 src/main/java/gift/web/dto/form/UpdateProductForm.java create mode 100644 src/main/java/gift/web/dto/request/LoginRequest.java create mode 100644 src/main/java/gift/web/dto/request/category/CreateCategoryRequest.java create mode 100644 src/main/java/gift/web/dto/request/category/UpdateCategoryRequest.java create mode 100644 src/main/java/gift/web/dto/request/member/CreateMemberRequest.java create mode 100644 src/main/java/gift/web/dto/request/product/CreateProductRequest.java create mode 100644 src/main/java/gift/web/dto/request/product/UpdateProductRequest.java create mode 100644 src/main/java/gift/web/dto/request/productoption/CreateProductOptionRequest.java create mode 100644 src/main/java/gift/web/dto/request/productoption/SubtractProductOptionQuantityRequest.java create mode 100644 src/main/java/gift/web/dto/request/productoption/UpdateProductOptionRequest.java create mode 100644 src/main/java/gift/web/dto/request/wishproduct/CreateWishProductRequest.java create mode 100644 src/main/java/gift/web/dto/request/wishproduct/UpdateWishProductRequest.java create mode 100644 src/main/java/gift/web/dto/response/ErrorResponse.java create mode 100644 src/main/java/gift/web/dto/response/LoginResponse.java create mode 100644 src/main/java/gift/web/dto/response/category/CreateCategoryResponse.java create mode 100644 src/main/java/gift/web/dto/response/category/ReadAllCategoriesResponse.java create mode 100644 src/main/java/gift/web/dto/response/category/ReadCategoryResponse.java create mode 100644 src/main/java/gift/web/dto/response/category/UpdateCategoryResponse.java create mode 100644 src/main/java/gift/web/dto/response/member/CreateMemberResponse.java create mode 100644 src/main/java/gift/web/dto/response/member/ReadMemberResponse.java create mode 100644 src/main/java/gift/web/dto/response/product/CreateProductResponse.java create mode 100644 src/main/java/gift/web/dto/response/product/ReadAllProductsResponse.java create mode 100644 src/main/java/gift/web/dto/response/product/ReadProductResponse.java create mode 100644 src/main/java/gift/web/dto/response/product/UpdateProductResponse.java create mode 100644 src/main/java/gift/web/dto/response/productoption/CreateProductOptionResponse.java create mode 100644 src/main/java/gift/web/dto/response/productoption/ReadAllProductOptionsResponse.java create mode 100644 src/main/java/gift/web/dto/response/productoption/ReadProductOptionResponse.java create mode 100644 src/main/java/gift/web/dto/response/productoption/SubtractProductOptionQuantityResponse.java create mode 100644 src/main/java/gift/web/dto/response/productoption/UpdateProductOptionResponse.java create mode 100644 src/main/java/gift/web/dto/response/wishproduct/CreateWishProductResponse.java create mode 100644 src/main/java/gift/web/dto/response/wishproduct/ReadAllWishProductsResponse.java create mode 100644 src/main/java/gift/web/dto/response/wishproduct/ReadWishProductResponse.java create mode 100644 src/main/java/gift/web/dto/response/wishproduct/UpdateWishProductResponse.java create mode 100644 src/main/java/gift/web/resolver/LoginMemberArgumentResolver.java create mode 100644 src/main/java/gift/web/validation/constraints/HexColor.java create mode 100644 src/main/java/gift/web/validation/constraints/Password.java create mode 100644 src/main/java/gift/web/validation/constraints/RequiredKakaoApproval.java create mode 100644 src/main/java/gift/web/validation/constraints/SpecialCharacter.java create mode 100644 src/main/java/gift/web/validation/exception/CustomException.java create mode 100644 src/main/java/gift/web/validation/exception/client/AlreadyExistsException.java create mode 100644 src/main/java/gift/web/validation/exception/client/BadRequestException.java create mode 100644 src/main/java/gift/web/validation/exception/client/IncorrectEmailException.java create mode 100644 src/main/java/gift/web/validation/exception/client/IncorrectPasswordException.java create mode 100644 src/main/java/gift/web/validation/exception/client/InvalidCredentialsException.java create mode 100644 src/main/java/gift/web/validation/exception/client/ResourceNotFoundException.java create mode 100644 src/main/java/gift/web/validation/exception/code/Category.java create mode 100644 src/main/java/gift/web/validation/exception/code/ErrorStatus.java create mode 100644 src/main/java/gift/web/validation/exception/server/InternalServerException.java create mode 100644 src/main/java/gift/web/validation/handler/ApiExceptionHandler.java create mode 100644 src/main/java/gift/web/validation/validator/HexColorValidator.java create mode 100644 src/main/java/gift/web/validation/validator/KakaoApprovalValidator.java create mode 100644 src/main/java/gift/web/validation/validator/PasswordValidator.java create mode 100644 src/main/java/gift/web/validation/validator/SpecialCharacterValidator.java create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application-secret.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/sql/data.sql create mode 100644 src/main/resources/sql/schema.sql create mode 100644 src/main/resources/static/index.html create mode 100644 src/main/resources/static/js/script.js create mode 100644 src/main/resources/templates/admin.html create mode 100644 src/main/resources/templates/form/add-product-form.html create mode 100644 src/main/resources/templates/form/edit-product-form.html create mode 100644 src/test/java/gift/domain/ProductTest.java create mode 100644 src/test/java/gift/service/CategoryServiceTest.java create mode 100644 src/test/java/gift/service/MemberServiceTest.java create mode 100644 src/test/java/gift/service/ProductOptionServiceTest.java create mode 100644 src/test/java/gift/service/ProductServiceTest.java create mode 100644 src/test/java/gift/service/WishProductServiceTest.java create mode 100644 src/test/java/gift/utils/CategoryDummyDataProvider.java create mode 100644 src/test/java/gift/utils/DatabaseCleanup.java create mode 100644 src/test/java/gift/utils/MemberDummyDataProvider.java create mode 100644 src/test/java/gift/utils/ProductDummyDataProvider.java create mode 100644 src/test/java/gift/utils/StringUtilsTest.java create mode 100644 src/test/java/gift/utils/WishProductDummyDataProvider.java create mode 100644 src/test/java/gift/web/controller/api/MemberApiControllerTest.java create mode 100644 src/test/resources/application-test.yml diff --git a/.gitignore b/.gitignore index 0caf866b0..281fb4a5b 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Mac OS ### .DS_Store + +### secret files ### +#src/main/resources/application-secret.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle index df7db9334..9ffb4a4b1 100644 --- a/build.gradle +++ b/build.gradle @@ -19,8 +19,16 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + //jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/gift/Application.java b/src/main/java/gift/Application.java index 61603cca0..037286d2c 100644 --- a/src/main/java/gift/Application.java +++ b/src/main/java/gift/Application.java @@ -2,10 +2,15 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing(auditorAwareRef = "memberIdAuditorAware") @SpringBootApplication public class Application { + public static void main(String[] args) { - SpringApplication.run(Application.class, args); + SpringApplication app = new SpringApplication(Application.class); + app.setAdditionalProfiles("local"); + app.run(args); } } diff --git a/src/main/java/gift/authentication/annotation/LoginMember.java b/src/main/java/gift/authentication/annotation/LoginMember.java new file mode 100644 index 000000000..3f051341b --- /dev/null +++ b/src/main/java/gift/authentication/annotation/LoginMember.java @@ -0,0 +1,12 @@ +package gift.authentication.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginMember { + +} diff --git a/src/main/java/gift/authentication/filter/AuthenticationExceptionHandlerFilter.java b/src/main/java/gift/authentication/filter/AuthenticationExceptionHandlerFilter.java new file mode 100644 index 000000000..7cf460f49 --- /dev/null +++ b/src/main/java/gift/authentication/filter/AuthenticationExceptionHandlerFilter.java @@ -0,0 +1,32 @@ +package gift.authentication.filter; + +import gift.utils.JsonUtils; +import gift.web.dto.response.ErrorResponse; +import gift.web.validation.exception.client.InvalidCredentialsException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.http.HttpStatus; +import org.springframework.web.filter.OncePerRequestFilter; + +public class AuthenticationExceptionHandlerFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + filterChain.doFilter(request, response); + } catch (JwtException e) { + ErrorResponse errorResponse = ErrorResponse.from(new InvalidCredentialsException()); + String errorResponseJson = JsonUtils.toJson(errorResponse); + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.getWriter().write(errorResponseJson); + } + } +} diff --git a/src/main/java/gift/authentication/filter/AuthenticationFilter.java b/src/main/java/gift/authentication/filter/AuthenticationFilter.java new file mode 100644 index 000000000..19386bfe2 --- /dev/null +++ b/src/main/java/gift/authentication/filter/AuthenticationFilter.java @@ -0,0 +1,51 @@ +package gift.authentication.filter; + +import gift.authentication.token.JwtResolver; +import gift.authentication.token.Token; +import gift.authentication.token.TokenContext; +import gift.web.validation.exception.client.InvalidCredentialsException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import org.springframework.web.filter.OncePerRequestFilter; + +public class AuthenticationFilter extends OncePerRequestFilter { + + private final List ignorePaths = List.of("/api/members/login", "/api/members/register"); + private final String AUTHORIZATION_HEADER = "Authorization"; + private final String BEARER = "Bearer "; + private final JwtResolver jwtResolver; + + public AuthenticationFilter(JwtResolver jwtResolver) { + this.jwtResolver = jwtResolver; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String authorization = request.getHeader(AUTHORIZATION_HEADER); + if(Objects.nonNull(authorization) && authorization.startsWith(BEARER)) { + String token = authorization.substring(BEARER.length()); + + Long memberId = jwtResolver.resolveId(Token.from(token)).orElseThrow(InvalidCredentialsException::new); + TokenContext.addCurrentMemberId(memberId); + + filterChain.doFilter(request, response); + TokenContext.clear(); + return; + } + + throw new JwtException("Invalid token"); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + return ignorePaths.contains(request.getRequestURI()); + } +} diff --git a/src/main/java/gift/authentication/token/JwtProvider.java b/src/main/java/gift/authentication/token/JwtProvider.java new file mode 100644 index 000000000..1080dd9c9 --- /dev/null +++ b/src/main/java/gift/authentication/token/JwtProvider.java @@ -0,0 +1,39 @@ +package gift.authentication.token; + +import gift.domain.Member; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtProvider { + + private final SecretKey key; + + @Value("${jwt.expiration}") + private long expirationTime; + + private final String MEMBER_ID_CLAIM_KEY = "memberId"; + + public JwtProvider(@Value("${jwt.secretkey}") String secret) { + key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + } + + public Token generateToken(Member member) { + long nowMillis = System.currentTimeMillis(); + Date now = new Date(nowMillis); + Date expiration = new Date(nowMillis + expirationTime); + + return Token.from(Jwts.builder() + .claim(MEMBER_ID_CLAIM_KEY, member.getId()) + .issuedAt(now) + .expiration(expiration) + .signWith(key) + .compact()); + } + +} diff --git a/src/main/java/gift/authentication/token/JwtResolver.java b/src/main/java/gift/authentication/token/JwtResolver.java new file mode 100644 index 000000000..998484281 --- /dev/null +++ b/src/main/java/gift/authentication/token/JwtResolver.java @@ -0,0 +1,34 @@ +package gift.authentication.token; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.util.Optional; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtResolver { + + private final SecretKey key; + private final String MEMBER_ID_CLAIM_KEY = "memberId"; + + public JwtResolver(@Value("${jwt.secretkey}") String secret) { + this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + } + + public Claims resolve(Token token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token.getValue()) + .getPayload(); + } + + public Optional resolveId(Token token) { + return Optional.ofNullable(resolve(token).get(MEMBER_ID_CLAIM_KEY, Long.class)); + } + +} diff --git a/src/main/java/gift/authentication/token/Token.java b/src/main/java/gift/authentication/token/Token.java new file mode 100644 index 000000000..8ff73f026 --- /dev/null +++ b/src/main/java/gift/authentication/token/Token.java @@ -0,0 +1,21 @@ +package gift.authentication.token; + +public class Token { + + private String value; + + private Token() { + } + + private Token(String value) { + this.value = value; + } + + public static Token from(String value) { + return new Token(value); + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/gift/authentication/token/TokenContext.java b/src/main/java/gift/authentication/token/TokenContext.java new file mode 100644 index 000000000..e992e8e11 --- /dev/null +++ b/src/main/java/gift/authentication/token/TokenContext.java @@ -0,0 +1,20 @@ +package gift.authentication.token; + +import java.util.Optional; + +public abstract class TokenContext { + + private static final ThreadLocal currentMember = new ThreadLocal<>(); + + public static void addCurrentMemberId(Long memberId) { + currentMember.set(memberId); + } + + public static Optional getCurrentMemberId() { + return Optional.ofNullable(currentMember.get()); + } + + public static void clear() { + currentMember.remove(); + } +} diff --git a/src/main/java/gift/config/WebConfig.java b/src/main/java/gift/config/WebConfig.java new file mode 100644 index 000000000..e5dc82b40 --- /dev/null +++ b/src/main/java/gift/config/WebConfig.java @@ -0,0 +1,46 @@ +package gift.config; + +import gift.authentication.filter.AuthenticationExceptionHandlerFilter; +import gift.authentication.filter.AuthenticationFilter; +import gift.authentication.token.JwtResolver; +import gift.web.resolver.LoginMemberArgumentResolver; +import java.util.List; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + private final LoginMemberArgumentResolver loginUserArgumentResolver; + private final JwtResolver jwtResolver; + + public WebConfig(LoginMemberArgumentResolver loginUserArgumentResolver, JwtResolver jwtResolver) { + this.loginUserArgumentResolver = loginUserArgumentResolver; + this.jwtResolver = jwtResolver; + } + + @Bean + public FilterRegistrationBean authenticationExceptionHandlerFilter() { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(); + filterRegistrationBean.setFilter(new AuthenticationExceptionHandlerFilter()); + filterRegistrationBean.setOrder(1); + return filterRegistrationBean; + } + + @Bean + public FilterRegistrationBean authenticationFilter() { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(); + filterRegistrationBean.setFilter(new AuthenticationFilter(jwtResolver)); + filterRegistrationBean.addUrlPatterns("/api/*"); + filterRegistrationBean.setOrder(2); + return filterRegistrationBean; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginUserArgumentResolver); + } + +} diff --git a/src/main/java/gift/converter/StringToUrlConverter.java b/src/main/java/gift/converter/StringToUrlConverter.java new file mode 100644 index 000000000..76426ad25 --- /dev/null +++ b/src/main/java/gift/converter/StringToUrlConverter.java @@ -0,0 +1,20 @@ +package gift.converter; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; + +public final class StringToUrlConverter { + + private StringToUrlConverter() { + } + + public static URL convert(String source) { + try { + return new URI(source).toURL(); + } catch (MalformedURLException | URISyntaxException e) { + throw new IllegalArgumentException("유효하지 않은 URL 형식입니다."); + } + } +} diff --git a/src/main/java/gift/domain/Category.java b/src/main/java/gift/domain/Category.java new file mode 100644 index 000000000..a49b60b88 --- /dev/null +++ b/src/main/java/gift/domain/Category.java @@ -0,0 +1,99 @@ +package gift.domain; + +import gift.domain.base.BaseEntity; +import gift.domain.base.BaseTimeEntity; +import gift.domain.vo.Color; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import java.net.URL; +import org.hibernate.annotations.ColumnDefault; + +@Entity +public class Category extends BaseEntity { + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String description; + + @Column(nullable = false) + @ColumnDefault("'https://gift-s3.s3.ap-northeast-2.amazonaws.com/default-image.png'") + private URL imageUrl; + + @Column(nullable = false) + private Color color; + + protected Category() { + } + + public static class Builder extends BaseTimeEntity.Builder { + + private String name; + private String description; + private URL imageUrl; + private Color color; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder imageUrl(URL imageUrl) { + this.imageUrl = imageUrl; + return this; + } + + public Builder color(Color color) { + this.color = color; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public Category build() { + return new Category(this); + } + } + + private Category(Builder builder) { + super(builder); + name = builder.name; + description = builder.description; + imageUrl = builder.imageUrl; + color = builder.color; + } + + public Category update(Category category) { + name = category.name; + description = category.description; + imageUrl = category.imageUrl; + color = category.color; + return this; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public URL getImageUrl() { + return imageUrl; + } + + public Color getColor() { + return color; + } +} \ No newline at end of file diff --git a/src/main/java/gift/domain/Member.java b/src/main/java/gift/domain/Member.java new file mode 100644 index 000000000..bdc25c983 --- /dev/null +++ b/src/main/java/gift/domain/Member.java @@ -0,0 +1,82 @@ +package gift.domain; + +import gift.domain.base.BaseTimeEntity; +import gift.domain.vo.Email; +import gift.domain.vo.Password; +import gift.web.validation.exception.client.IncorrectPasswordException; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; + +@Entity +public class Member extends BaseTimeEntity { + + @Embedded + private Email email; + + @Embedded + private Password password; + + @Column(nullable = false) + private String name; + + protected Member() { + } + + public static class Builder extends BaseTimeEntity.Builder { + + private Email email; + private Password password; + private String name; + + public Builder email(Email email) { + this.email = email; + return this; + } + + public Builder password(Password password) { + this.password = password; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public Member build() { + return new Member(this); + } + } + + private Member(Builder builder) { + super(builder); + email = builder.email; + password = builder.password; + name = builder.name; + } + + public Email getEmail() { + return email; + } + + public Password getPassword() { + return password; + } + + public String getName() { + return name; + } + + public void matchPassword(String password) { + if (!this.password.matches(password)) { + throw new IncorrectPasswordException(); + } + } +} diff --git a/src/main/java/gift/domain/Product.java b/src/main/java/gift/domain/Product.java new file mode 100644 index 000000000..dff5f0613 --- /dev/null +++ b/src/main/java/gift/domain/Product.java @@ -0,0 +1,134 @@ +package gift.domain; + +import gift.domain.base.BaseEntity; +import gift.domain.base.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.net.URL; +import java.util.List; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +@DynamicInsert +@Entity +public class Product extends BaseEntity { + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Integer price; + + @Column(nullable = false) + @ColumnDefault("'https://gift-s3.s3.ap-northeast-2.amazonaws.com/default-image.png'") + private URL imageUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT), nullable = false) + private Category category; + + @OneToMany(mappedBy = "productId", fetch = FetchType.LAZY) + private List productOptions; + + protected Product() { + } + + public static class Builder extends BaseTimeEntity.Builder { + + private String name; + private Integer price; + private URL imageUrl; + private Category category; + private List productOptions; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder price(Integer price) { + this.price = price; + return this; + } + + public Builder imageUrl(URL imageUrl) { + this.imageUrl = imageUrl; + return this; + } + + public Builder category(Category category) { + this.category = category; + return this; + } + + public Builder productOptions(List productOptions) { + this.productOptions = productOptions; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public Product build() { + return new Product(this); + } + + private void validateProductOptionsPresence(List productOptions) { + if (productOptions == null || productOptions.isEmpty()) { + throw new IllegalArgumentException("상품 옵션은 최소 1개 이상이어야 합니다."); + } + } + } + + private Product(Builder builder) { + super(builder); + name = builder.name; + price = builder.price; + imageUrl = builder.imageUrl; + category = builder.category; + productOptions = builder.productOptions; + validateProductOptionsPresence(productOptions); + } + + public Product updateBasicInfo(String name, Integer price, URL imageUrl) { + this.name = name; + this.price = price; + this.imageUrl = imageUrl; + return this; + } + + private void validateProductOptionsPresence(List productOptions) { + if (productOptions == null || productOptions.isEmpty()) { + throw new IllegalArgumentException("상품 옵션은 최소 1개 이상이어야 합니다."); + } + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public URL getImageUrl() { + return imageUrl; + } + + public Category getCategory() { + return category; + } + + public List getProductOptions() { + return productOptions; + } +} diff --git a/src/main/java/gift/domain/ProductOption.java b/src/main/java/gift/domain/ProductOption.java new file mode 100644 index 000000000..81842896c --- /dev/null +++ b/src/main/java/gift/domain/ProductOption.java @@ -0,0 +1,90 @@ +package gift.domain; + +import gift.domain.base.BaseEntity; +import gift.domain.base.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; + +@Entity +public class ProductOption extends BaseEntity { + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Integer stock; + + private Long productId; + + protected ProductOption() { + } + + public static class Builder extends BaseTimeEntity.Builder { + + private String name; + private Integer stock; + private Long productId; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder stock(Integer stock) { + this.stock = stock; + return this; + } + + public Builder productId(Long productId) { + this.productId = productId; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public ProductOption build() { + return new ProductOption(this); + } + } + + private ProductOption(Builder builder) { + super(builder); + name = builder.name; + stock = builder.stock; + productId = builder.productId; + } + + public ProductOption update(ProductOption option) { + this.name = option.name; + this.stock = option.stock; + return this; + } + + public ProductOption subtractQuantity(Integer quantity) { + validatedRequestQuantity(quantity); + this.stock -= quantity; + return this; + } + + private void validatedRequestQuantity(int quantity) { + if(quantity > stock) { + throw new IllegalStateException("재고보다 많은 수량을 요청할 수 없습니다."); + } + } + + public String getName() { + return name; + } + + public Integer getStock() { + return stock; + } + + public Long getProductId() { + return productId; + } +} diff --git a/src/main/java/gift/domain/WishProduct.java b/src/main/java/gift/domain/WishProduct.java new file mode 100644 index 000000000..25d016cec --- /dev/null +++ b/src/main/java/gift/domain/WishProduct.java @@ -0,0 +1,88 @@ +package gift.domain; + +import gift.domain.base.BaseEntity; +import gift.domain.base.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +@Entity +public class WishProduct extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + @Column(nullable = false) + private Integer quantity; + + protected WishProduct() { + } + + public static class Builder extends BaseTimeEntity.Builder { + + private Member member; + private Product product; + private Integer quantity; + + public Builder member(Member member) { + this.member = member; + return this; + } + + public Builder product(Product product) { + this.product = product; + return this; + } + + public Builder quantity(Integer quantity) { + this.quantity = quantity; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public WishProduct build() { + return new WishProduct(this); + } + } + + private WishProduct(Builder builder) { + super(builder); + member = builder.member; + product = builder.product; + quantity = builder.quantity; + } + + public Member getMember() { + return member; + } + + public Product getProduct() { + return product; + } + + public Integer getQuantity() { + return quantity; + } + + public Integer updateQuantity(Integer quantity) { + this.quantity = quantity; + return this.quantity; + } + + public Integer addQuantity(Integer quantity) { + this.quantity += quantity; + return this.quantity; + } +} diff --git a/src/main/java/gift/domain/base/BaseEntity.java b/src/main/java/gift/domain/base/BaseEntity.java new file mode 100644 index 000000000..61f23a72d --- /dev/null +++ b/src/main/java/gift/domain/base/BaseEntity.java @@ -0,0 +1,28 @@ +package gift.domain.base; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity extends BaseTimeEntity { + + @CreatedBy + @Column(nullable = false, updatable = false) + private Long createdBy; + + @LastModifiedBy + @Column(nullable = false) + private Long updatedBy; + + protected BaseEntity() { + } + + protected BaseEntity(Builder builder) { + super(builder); + } +} diff --git a/src/main/java/gift/domain/base/BaseTimeEntity.java b/src/main/java/gift/domain/base/BaseTimeEntity.java new file mode 100644 index 000000000..b492e6007 --- /dev/null +++ b/src/main/java/gift/domain/base/BaseTimeEntity.java @@ -0,0 +1,54 @@ +package gift.domain.base; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + protected BaseTimeEntity() { + } + + protected abstract static class Builder> { + + Long id; + + public T id(Long id) { + this.id = id; + return self(); + } + + protected abstract BaseTimeEntity build(); + + protected abstract T self(); + } + + protected BaseTimeEntity(Builder builder) { + id = builder.id; + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/gift/domain/vo/Color.java b/src/main/java/gift/domain/vo/Color.java new file mode 100644 index 000000000..01f3a760b --- /dev/null +++ b/src/main/java/gift/domain/vo/Color.java @@ -0,0 +1,49 @@ +package gift.domain.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +@Embeddable +public class Color { + + @Column(name = "color", nullable = false) + private String value; + + protected Color() { + } + + private Color(String value) { + this.value = value; + } + + public static Color from(String color) { + return new Color(color); + } + + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Color e = (Color) o; + return Objects.equals(this.value, e.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/gift/domain/vo/Email.java b/src/main/java/gift/domain/vo/Email.java new file mode 100644 index 000000000..d83213803 --- /dev/null +++ b/src/main/java/gift/domain/vo/Email.java @@ -0,0 +1,44 @@ +package gift.domain.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +@Embeddable +public class Email { + + @Column(name = "email", nullable = false, unique = true) + private String value; + + protected Email() { + } + + private Email(String value) { + this.value = value; + } + + public static Email from(String email) { + return new Email(email); + } + + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Email e = (Email) o; + return Objects.equals(this.value, e.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } +} diff --git a/src/main/java/gift/domain/vo/Password.java b/src/main/java/gift/domain/vo/Password.java new file mode 100644 index 000000000..d72f9804b --- /dev/null +++ b/src/main/java/gift/domain/vo/Password.java @@ -0,0 +1,48 @@ +package gift.domain.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +@Embeddable +public class Password { + + @Column(name = "password", nullable = false) + private String value; + + protected Password() { + } + + private Password(String value) { + this.value = value; + } + + public static Password from(String password) { + return new Password(password); + } + + public boolean matches(String password) { + return this.value.equals(password); + } + + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Password p = (Password) o; + return Objects.equals(this.value, p.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } +} diff --git a/src/main/java/gift/repository/CategoryRepository.java b/src/main/java/gift/repository/CategoryRepository.java new file mode 100644 index 000000000..04ffe7698 --- /dev/null +++ b/src/main/java/gift/repository/CategoryRepository.java @@ -0,0 +1,10 @@ +package gift.repository; + +import gift.domain.Category; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CategoryRepository extends JpaRepository { + +} diff --git a/src/main/java/gift/repository/MemberIdAuditorAware.java b/src/main/java/gift/repository/MemberIdAuditorAware.java new file mode 100644 index 000000000..4fc1a0b78 --- /dev/null +++ b/src/main/java/gift/repository/MemberIdAuditorAware.java @@ -0,0 +1,15 @@ +package gift.repository; + +import gift.authentication.token.TokenContext; +import java.util.Optional; +import org.springframework.data.domain.AuditorAware; +import org.springframework.stereotype.Component; + +@Component +public class MemberIdAuditorAware implements AuditorAware { + + @Override + public Optional getCurrentAuditor() { + return TokenContext.getCurrentMemberId(); + } +} diff --git a/src/main/java/gift/repository/MemberRepository.java b/src/main/java/gift/repository/MemberRepository.java new file mode 100644 index 000000000..7ea6e2b3b --- /dev/null +++ b/src/main/java/gift/repository/MemberRepository.java @@ -0,0 +1,14 @@ +package gift.repository; + +import gift.domain.Member; +import gift.domain.vo.Email; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(Email email); + +} diff --git a/src/main/java/gift/repository/ProductOptionRepository.java b/src/main/java/gift/repository/ProductOptionRepository.java new file mode 100644 index 000000000..344217d05 --- /dev/null +++ b/src/main/java/gift/repository/ProductOptionRepository.java @@ -0,0 +1,31 @@ +package gift.repository; + +import gift.domain.ProductOption; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductOptionRepository extends JpaRepository { + + List findAllByProductId(Long productId); + + boolean existsByNameAndProductId(String name, Long productId); + + /** + * 해당 상품 내에서 현재 옵션을 제외하고 이름이 같은 옵션이 있는지 확인 + * @param id 현재 상품 옵션 아이디 + * @param productId 상품 아이디 + * @param name 상품 옵션 이름 + * @return 현재 옵션을 제외한 중복된 상품 옵션 아이디 + */ + @Query("SELECT po.id" + + " FROM ProductOption po" + + " WHERE po.id != :id AND po.productId = :productId AND po.name = :name" + ) + Optional findDuplicatedProductOption(Long id, Long productId, String name); + + void deleteAllByProductId(Long productId); +} diff --git a/src/main/java/gift/repository/ProductRepository.java b/src/main/java/gift/repository/ProductRepository.java new file mode 100644 index 000000000..0f26e8d25 --- /dev/null +++ b/src/main/java/gift/repository/ProductRepository.java @@ -0,0 +1,14 @@ +package gift.repository; + +import gift.domain.Product; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductRepository extends JpaRepository { + + List findByCategoryId(Long categoryId, Pageable pageable); + +} diff --git a/src/main/java/gift/repository/WishProductRepository.java b/src/main/java/gift/repository/WishProductRepository.java new file mode 100644 index 000000000..3c1f2cc93 --- /dev/null +++ b/src/main/java/gift/repository/WishProductRepository.java @@ -0,0 +1,20 @@ +package gift.repository; + +import gift.domain.WishProduct; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WishProductRepository extends JpaRepository { + + List findByMemberId(Long memberId, Pageable pageable); + + Optional findByMemberIdAndProductId(Long memberId, Long productId); + + void deleteAllByProductId(Long productId); + + void deleteAllByMemberId(Long memberId); +} diff --git a/src/main/java/gift/service/CategoryService.java b/src/main/java/gift/service/CategoryService.java new file mode 100644 index 000000000..5c0ab5f51 --- /dev/null +++ b/src/main/java/gift/service/CategoryService.java @@ -0,0 +1,57 @@ +package gift.service; + +import gift.domain.Category; +import gift.repository.CategoryRepository; +import gift.web.dto.request.category.CreateCategoryRequest; +import gift.web.dto.request.category.UpdateCategoryRequest; +import gift.web.dto.response.category.CreateCategoryResponse; +import gift.web.dto.response.category.ReadAllCategoriesResponse; +import gift.web.dto.response.category.ReadCategoryResponse; +import gift.web.dto.response.category.UpdateCategoryResponse; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class CategoryService { + + private final CategoryRepository categoryRepository; + + public CategoryService(CategoryRepository categoryRepository) { + this.categoryRepository = categoryRepository; + } + + @Transactional + public CreateCategoryResponse createCategory(CreateCategoryRequest request) { + Category category = categoryRepository.save(request.toEntity()); + return CreateCategoryResponse.fromEntity(category); + } + + public ReadAllCategoriesResponse readAllCategories(Pageable pageable) { + List categories = categoryRepository.findAll(pageable).stream() + .map(ReadCategoryResponse::fromEntity) + .toList(); + return new ReadAllCategoriesResponse(categories); + } + + public ReadCategoryResponse readCategory(Long id) { + Category category = categoryRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException(id + "에 해당하는 카테고리가 없습니다.")); + return ReadCategoryResponse.fromEntity(category); + } + + @Transactional + public UpdateCategoryResponse updateCategory(Long id, UpdateCategoryRequest request) { + Category category = categoryRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException(id + "에 해당하는 카테고리가 없습니다.")); + category.update(request.toEntity()); + return UpdateCategoryResponse.fromEntity(category); + } + + @Transactional + public void deleteCategory(Long id) { + categoryRepository.deleteById(id); + } +} diff --git a/src/main/java/gift/service/MemberDetailsService.java b/src/main/java/gift/service/MemberDetailsService.java new file mode 100644 index 000000000..9ba39a70f --- /dev/null +++ b/src/main/java/gift/service/MemberDetailsService.java @@ -0,0 +1,22 @@ +package gift.service; + +import gift.repository.MemberRepository; +import gift.web.dto.MemberDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class MemberDetailsService { + + private final MemberRepository memberRepository; + + public MemberDetailsService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + public MemberDetails loadUserById(Long id) { + return MemberDetails.from(memberRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다. id: " + id))); + } +} diff --git a/src/main/java/gift/service/MemberService.java b/src/main/java/gift/service/MemberService.java new file mode 100644 index 000000000..a2aef65cd --- /dev/null +++ b/src/main/java/gift/service/MemberService.java @@ -0,0 +1,64 @@ +package gift.service; + +import gift.authentication.token.JwtProvider; +import gift.authentication.token.Token; +import gift.domain.Member; +import gift.domain.vo.Email; +import gift.repository.MemberRepository; +import gift.repository.WishProductRepository; +import gift.web.dto.request.LoginRequest; +import gift.web.dto.request.member.CreateMemberRequest; +import gift.web.dto.response.LoginResponse; +import gift.web.dto.response.member.CreateMemberResponse; +import gift.web.dto.response.member.ReadMemberResponse; +import gift.web.validation.exception.client.IncorrectEmailException; +import java.util.NoSuchElementException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final WishProductRepository wishProductRepository; + private final JwtProvider jwtProvider; + + public MemberService(MemberRepository memberRepository, + WishProductRepository wishProductRepository, + JwtProvider jwtProvider) { + this.memberRepository = memberRepository; + this.wishProductRepository = wishProductRepository; + this.jwtProvider = jwtProvider; + } + + @Transactional + public CreateMemberResponse createMember(CreateMemberRequest request) { + Member member = request.toEntity(); + return CreateMemberResponse.fromEntity(memberRepository.save(member)); + } + + public ReadMemberResponse readMember(Long id) { + Member member = memberRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다. id: " + id)); + + return ReadMemberResponse.fromEntity(member); + } + + public void deleteMember(Long id) { + Member member = memberRepository.findById(id).orElseThrow(NoSuchElementException::new); + wishProductRepository.deleteAllByMemberId(id); + memberRepository.delete(member); + } + + public LoginResponse login(LoginRequest request) { + Email email = Email.from(request.getEmail()); + Member member = memberRepository.findByEmail(email).orElseThrow(() -> new IncorrectEmailException(email.getValue())); + + member.matchPassword(request.getPassword()); + + Token token = jwtProvider.generateToken(member); + + return new LoginResponse(token); + } +} diff --git a/src/main/java/gift/service/ProductOptionService.java b/src/main/java/gift/service/ProductOptionService.java new file mode 100644 index 000000000..9663b89a5 --- /dev/null +++ b/src/main/java/gift/service/ProductOptionService.java @@ -0,0 +1,146 @@ +package gift.service; + +import gift.domain.ProductOption; +import gift.repository.ProductOptionRepository; +import gift.web.dto.request.productoption.CreateProductOptionRequest; +import gift.web.dto.request.productoption.SubtractProductOptionQuantityRequest; +import gift.web.dto.request.productoption.UpdateProductOptionRequest; +import gift.web.dto.response.productoption.CreateProductOptionResponse; +import gift.web.dto.response.productoption.ReadAllProductOptionsResponse; +import gift.web.dto.response.productoption.ReadProductOptionResponse; +import gift.web.dto.response.productoption.SubtractProductOptionQuantityResponse; +import gift.web.dto.response.productoption.UpdateProductOptionResponse; +import gift.web.validation.exception.client.AlreadyExistsException; +import gift.web.validation.exception.client.ResourceNotFoundException; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class ProductOptionService { + + private final ProductOptionRepository productOptionRepository; + + public ProductOptionService(ProductOptionRepository productOptionRepository) { + this.productOptionRepository = productOptionRepository; + } + + @Transactional + public CreateProductOptionResponse createOption(Long productId, CreateProductOptionRequest request) { + String optionName = request.getName(); + validateOptionNameExists(productId, optionName); + + ProductOption createdOption = productOptionRepository.save(request.toEntity(productId)); + + return CreateProductOptionResponse.fromEntity(createdOption); + } + + /** + * 상품 옵션 이름이 이미 존재하는지 확인합니다.
+ * 이미 존재한다면 {@link AlreadyExistsException} 을 발생시킵니다. + * @param productId 상품 아이디 + * @param name 상품 옵션 이름 + */ + private void validateOptionNameExists(Long productId, String name) { + validateOptionNameExists(null, productId, name); + } + + /** + * 상품 옵션 이름이 이미 존재하는지 확인합니다.
+ * 이미 존재한다면 {@link AlreadyExistsException} 을 발생시킵니다. + * @param optionId 현재 상품 옵션 아이디 (옵션 새로 생성 시 null) + * @param productId 상품 아이디 + * @param name 상품 옵션 이름 + */ + private void validateOptionNameExists(Long optionId, Long productId, String name) { + if(optionId == null) { + optionId = -1L; + } + productOptionRepository.findDuplicatedProductOption(optionId, productId, name) + .ifPresent(duplicatedOptionId -> { + throw new AlreadyExistsException(name); + }); + } + + /** + * 해당 상품에 옵션이 존재하지 않는 경우 최초 옵션 등록을 위해 사용됩니다. + * @param productId 상품 아이디 + * @param request 상품 옵션 생성 요청 + * @return 상품 옵션 생성 응답 + */ + public List createInitialOptions(Long productId, List request) { + List productOptions = request.stream() + .map(productOption -> productOption.toEntity(productId)) + .toList(); + validateDuplicateOptionNames(productOptions); + + List createdOptions = productOptionRepository.saveAll(productOptions); + + return createdOptions.stream() + .map(CreateProductOptionResponse::fromEntity) + .toList(); + } + + /** + * 상품 옵션 이름에 중복이 존재하는지 검열합니다
+ * 중복이 존재한다면 {@link IllegalStateException}을 발생시킵니다. + * @param productOptions 상품 옵션 리스트 + */ + private void validateDuplicateOptionNames(List productOptions) { + long originalCount = productOptions.size(); + long distinctCount = productOptions.stream() + .map(ProductOption::getName) + .distinct() + .count(); + + if (originalCount != distinctCount) { + throw new IllegalStateException("상품 옵션 이름에 중복이 존재합니다"); + } + } + + public ReadAllProductOptionsResponse readAllOptions(Long productId) { + List options = productOptionRepository.findAllByProductId(productId) + .stream() + .map(ReadProductOptionResponse::fromEntity) + .toList(); + return ReadAllProductOptionsResponse.from(options); + } + + @Transactional + public UpdateProductOptionResponse updateOption(Long optionId, Long productId, UpdateProductOptionRequest request) { + String optionName = request.getName(); + validateOptionNameExists(optionId, productId, optionName); + + ProductOption option = productOptionRepository.findById(optionId) + .orElseThrow(() -> new ResourceNotFoundException("상품 옵션", optionId.toString())); + + ProductOption updateParam = request.toEntity(); + option.update(updateParam); + + return UpdateProductOptionResponse.fromEntity(option); + } + + @Transactional + public SubtractProductOptionQuantityResponse subtractOptionStock(Long optionId, SubtractProductOptionQuantityRequest request) { + ProductOption option = productOptionRepository.findById(optionId) + .orElseThrow(() -> new ResourceNotFoundException("상품 옵션", optionId.toString())); + + option.subtractQuantity(request.getQuantity()); + + return SubtractProductOptionQuantityResponse.fromEntity(option); + } + + @Transactional + public void deleteOption(Long optionId) { + ProductOption option = productOptionRepository.findById(optionId) + .orElseThrow(() -> new ResourceNotFoundException("상품 옵션", optionId.toString())); + + productOptionRepository.delete(option); + } + + @Transactional + public void deleteAllOptionsByProductId(Long productId) { + productOptionRepository.deleteAllByProductId(productId); + } +} diff --git a/src/main/java/gift/service/ProductService.java b/src/main/java/gift/service/ProductService.java new file mode 100644 index 000000000..ece497258 --- /dev/null +++ b/src/main/java/gift/service/ProductService.java @@ -0,0 +1,99 @@ +package gift.service; + +import gift.converter.StringToUrlConverter; +import gift.domain.Category; +import gift.domain.Product; +import gift.repository.CategoryRepository; +import gift.repository.ProductRepository; +import gift.repository.WishProductRepository; +import gift.web.dto.request.product.CreateProductRequest; +import gift.web.dto.request.product.UpdateProductRequest; +import gift.web.dto.response.product.CreateProductResponse; +import gift.web.dto.response.product.ReadAllProductsResponse; +import gift.web.dto.response.product.ReadProductResponse; +import gift.web.dto.response.product.UpdateProductResponse; +import java.util.List; +import java.util.NoSuchElementException; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class ProductService { + + private final ProductRepository productRepository; + private final CategoryRepository categoryRepository; + private final WishProductRepository wishProductRepository; + private final ProductOptionService productOptionService; + + public ProductService(ProductRepository productRepository, + CategoryRepository categoryRepository, + WishProductRepository wishProductRepository, ProductOptionService productOptionService) { + this.productRepository = productRepository; + this.categoryRepository = categoryRepository; + this.wishProductRepository = wishProductRepository; + this.productOptionService = productOptionService; + } + + @Transactional + public CreateProductResponse createProduct(CreateProductRequest request) { + Category category = categoryRepository.findById(request.getCategoryId()) + .orElseThrow(NoSuchElementException::new); + + Product product = request.toEntity(category); + Product savedProduct = productRepository.save(product); + + productOptionService.createInitialOptions(savedProduct.getId(), request.getProductOptions()); + + return CreateProductResponse.fromEntity(savedProduct); + } + + public ReadProductResponse readProductById(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(NoSuchElementException::new); + return ReadProductResponse.fromEntity(product); + } + + public ReadAllProductsResponse readProductsByCategoryId(Long categoryId, Pageable pageable) { + List products = productRepository.findByCategoryId(categoryId, pageable) + .stream() + .map(ReadProductResponse::fromEntity) + .toList(); + return ReadAllProductsResponse.from(products); + } + + public ReadAllProductsResponse readAllProducts() { + List products = productRepository.findAll() + .stream() + .map(ReadProductResponse::fromEntity) + .toList(); + return ReadAllProductsResponse.from(products); + } + + public ReadAllProductsResponse readAllProducts(Pageable pageable) { + List products = productRepository.findAll(pageable) + .stream() + .map(ReadProductResponse::fromEntity) + .toList(); + return ReadAllProductsResponse.from(products); + } + + @Transactional + public UpdateProductResponse updateProduct(Long id, UpdateProductRequest request) { + Product product = productRepository.findById(id) + .orElseThrow(NoSuchElementException::new); + + product.updateBasicInfo(request.getName(), request.getPrice(), StringToUrlConverter.convert(request.getImageUrl())); + return UpdateProductResponse.from(product); + } + + @Transactional + public void deleteProduct(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(NoSuchElementException::new); + wishProductRepository.deleteAllByProductId(id); + productOptionService.deleteAllOptionsByProductId(id); + productRepository.delete(product); + } +} diff --git a/src/main/java/gift/service/WishProductService.java b/src/main/java/gift/service/WishProductService.java new file mode 100644 index 000000000..77d25ca78 --- /dev/null +++ b/src/main/java/gift/service/WishProductService.java @@ -0,0 +1,91 @@ +package gift.service; + +import gift.domain.WishProduct; +import gift.domain.WishProduct.Builder; +import gift.repository.MemberRepository; +import gift.repository.ProductRepository; +import gift.repository.WishProductRepository; +import gift.web.dto.request.wishproduct.CreateWishProductRequest; +import gift.web.dto.request.wishproduct.UpdateWishProductRequest; +import gift.web.dto.response.wishproduct.CreateWishProductResponse; +import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; +import gift.web.dto.response.wishproduct.ReadWishProductResponse; +import gift.web.dto.response.wishproduct.UpdateWishProductResponse; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class WishProductService { + + private final ProductRepository productRepository; + private final MemberRepository memberRepository; + private final WishProductRepository wishProductRepository; + + public WishProductService(ProductRepository productRepository, + MemberRepository memberRepository, + WishProductRepository wishProductRepository) { + this.productRepository = productRepository; + this.memberRepository = memberRepository; + this.wishProductRepository = wishProductRepository; + } + + /** + * 위시 리스트에 상품을 추가합니다. + * 이미 존재하는 WishProduct인 경우 수량만 추가합니다. + * @param memberId 회원 ID + * @param request 상품 정보 + * @return 생성된 WishProduct ID + */ + @Transactional + public CreateWishProductResponse createWishProduct(Long memberId, CreateWishProductRequest request) { + + // 이미 존재하는 WishProduct인 경우 수량만 추가 + Optional existingWishProduct = wishProductRepository.findByMemberIdAndProductId( + memberId, request.getProductId()); + if (existingWishProduct.isPresent()) { + WishProduct wishProduct = existingWishProduct.get(); + wishProduct.addQuantity(request.getQuantity()); + + return CreateWishProductResponse.fromEntity(wishProduct); + } + + //새로운 위시 상품을 추가 + WishProduct wishProduct = new Builder() + .member(memberRepository.findById(memberId) + .orElseThrow(() -> new NoSuchElementException("Member not found"))) + .product(productRepository.findById(request.getProductId()) + .orElseThrow(() -> new NoSuchElementException("Product not found"))) + .quantity(request.getQuantity()).build(); + + return CreateWishProductResponse.fromEntity(wishProductRepository.save(wishProduct)); + } + + public ReadAllWishProductsResponse readAllWishProducts(Long memberId, Pageable pageable) { + return new ReadAllWishProductsResponse( + wishProductRepository.findByMemberId(memberId, pageable) + .stream() + .map(ReadWishProductResponse::fromEntity) + .toList() + ); + } + + @Transactional + public UpdateWishProductResponse updateWishProduct(Long wishProductId, UpdateWishProductRequest request) { + WishProduct wishProduct = wishProductRepository.findById(wishProductId) + .orElseThrow(NoSuchElementException::new); + wishProduct.updateQuantity(request.getQuantity()); + + return UpdateWishProductResponse.fromEntity(wishProduct); + } + + @Transactional + public void deleteWishProduct(Long wishProductId) { + WishProduct wishProduct = wishProductRepository.findById(wishProductId) + .orElseThrow(NoSuchElementException::new); + wishProductRepository.delete(wishProduct); + } +} diff --git a/src/main/java/gift/utils/JsonUtils.java b/src/main/java/gift/utils/JsonUtils.java new file mode 100644 index 000000000..ffb89076f --- /dev/null +++ b/src/main/java/gift/utils/JsonUtils.java @@ -0,0 +1,27 @@ +package gift.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +public abstract class JsonUtils { + + private static final ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private JsonUtils() { + } + + /** + * JSON 형식의 문자열로 반환합니다 + * + * @param object 변환 대상 + * @return JSON 형식의 문자열 + * @throws JsonProcessingException + */ + public static String toJson(Object object) throws JsonProcessingException { + return objectMapper.writeValueAsString(object); + } +} diff --git a/src/main/java/gift/utils/StringUtils.java b/src/main/java/gift/utils/StringUtils.java new file mode 100644 index 000000000..148fc8ded --- /dev/null +++ b/src/main/java/gift/utils/StringUtils.java @@ -0,0 +1,34 @@ +package gift.utils; + +import java.util.Set; +import java.util.stream.Collectors; + +public abstract class StringUtils { + + /** + * 문자열과 문자열 집합을 전달받아, 집합에 포함된 어떤 문자열이라도 입력 문자열에 포함되어 있는지 검사합니다. + * + * @param input 검사할 문자열 + * @param substrings 검사할 문자열 집합 + * @return 집합에 포함된 어떤 문자열이라도 입력 문자열에 포함되어 있으면 true, 그렇지 않으면 false + */ + public static boolean containsAnySubstring(String input, Set substrings) { + return substrings.stream().anyMatch(input::contains); + } + + /** + * 문자열과 허용할 특수문자의 목록을 받아서 문자열이 허용할 특수문자 외의 특수문자가 있는지 검사합니다. + * + * @param input 문자열 입력 + * @param allowedSpecialChars 허용할 특수문자의 목록 + * @return 허용되지 않는 특수문자가 있으면 false, 그렇지 않으면 true + */ + public static boolean containsOnlyAllowedSpecialChars(String input, Set allowedSpecialChars) { + String allowedCharsRegex = allowedSpecialChars.stream() + .map(ch -> "\\" + ch) + .collect(Collectors.joining()); + + String regex = "[a-zA-Z0-9ㄱ-ㅎ가-힣ㅏ-ㅣ\\s" + allowedCharsRegex + "]+"; + return input.matches(regex); + } +} diff --git a/src/main/java/gift/web/controller/api/CategoryApiController.java b/src/main/java/gift/web/controller/api/CategoryApiController.java new file mode 100644 index 000000000..6ee5662a6 --- /dev/null +++ b/src/main/java/gift/web/controller/api/CategoryApiController.java @@ -0,0 +1,68 @@ +package gift.web.controller.api; + +import gift.service.CategoryService; +import gift.web.dto.request.category.CreateCategoryRequest; +import gift.web.dto.request.category.UpdateCategoryRequest; +import gift.web.dto.response.category.CreateCategoryResponse; +import gift.web.dto.response.category.ReadAllCategoriesResponse; +import gift.web.dto.response.category.ReadCategoryResponse; +import gift.web.dto.response.category.UpdateCategoryResponse; +import java.net.URI; +import java.net.URISyntaxException; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/categories") +public class CategoryApiController { + + private final CategoryService categoryService; + + public CategoryApiController(CategoryService categoryService) { + this.categoryService = categoryService; + } + + @PostMapping + public ResponseEntity createCategory(@Validated @RequestBody CreateCategoryRequest request) + throws URISyntaxException { + CreateCategoryResponse response = categoryService.createCategory(request); + + URI location = new URI("http://localhost:8080/api/categories/" + response.getId()); + return ResponseEntity.created(location).body(response); + } + + @GetMapping + public ResponseEntity readAllCategories(@PageableDefault Pageable pageable) { + ReadAllCategoriesResponse response = categoryService.readAllCategories(pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + public ResponseEntity readCategory(@PathVariable Long id) { + ReadCategoryResponse response = categoryService.readCategory(id); + return ResponseEntity.ok(response); + } + + @PutMapping("/{id}") + public ResponseEntity updateCategory(@PathVariable Long id, @Validated @RequestBody UpdateCategoryRequest request) { + UpdateCategoryResponse response = categoryService.updateCategory(id, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCategory(@PathVariable Long id) { + categoryService.deleteCategory(id); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/gift/web/controller/api/MemberApiController.java b/src/main/java/gift/web/controller/api/MemberApiController.java new file mode 100644 index 000000000..f0e468347 --- /dev/null +++ b/src/main/java/gift/web/controller/api/MemberApiController.java @@ -0,0 +1,77 @@ +package gift.web.controller.api; + +import gift.authentication.annotation.LoginMember; +import gift.service.MemberService; +import gift.service.WishProductService; +import gift.web.dto.MemberDetails; +import gift.web.dto.request.LoginRequest; +import gift.web.dto.request.member.CreateMemberRequest; +import gift.web.dto.request.wishproduct.UpdateWishProductRequest; +import gift.web.dto.response.LoginResponse; +import gift.web.dto.response.member.CreateMemberResponse; +import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; +import gift.web.dto.response.wishproduct.UpdateWishProductResponse; +import java.net.URI; +import java.net.URISyntaxException; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/members") +public class MemberApiController { + + private final MemberService memberService; + private final WishProductService wishProductService; + + public MemberApiController(MemberService memberService, WishProductService wishProductService) { + this.memberService = memberService; + this.wishProductService = wishProductService; + } + + @PostMapping("/register") + public ResponseEntity createMember( + @Validated @RequestBody CreateMemberRequest request) + throws URISyntaxException { + CreateMemberResponse response = memberService.createMember(request); + + URI location = new URI("http://localhost:8080/api/members/" + response.getId()); + return ResponseEntity.created(location).body(response); + } + + @PostMapping("/login") + public ResponseEntity login(@Validated @RequestBody LoginRequest request) { + LoginResponse response = memberService.login(request); + return ResponseEntity.ok(response); + } + + @GetMapping("/wishlist") + public ResponseEntity readWishProduct(@LoginMember MemberDetails memberDetails, @PageableDefault Pageable pageable) { + ReadAllWishProductsResponse response = wishProductService.readAllWishProducts(memberDetails.getId(), pageable); + return ResponseEntity.ok(response); + } + + @PutMapping("/wishlist/{wishProductId}") + public ResponseEntity updateWishProduct( + @PathVariable Long wishProductId, + @Validated @RequestBody UpdateWishProductRequest request) { + UpdateWishProductResponse response = wishProductService.updateWishProduct( + wishProductId, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/wishlist/{wishProductId}") + public ResponseEntity deleteWishProduct(@PathVariable Long wishProductId) { + wishProductService.deleteWishProduct(wishProductId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gift/web/controller/api/ProductApiController.java b/src/main/java/gift/web/controller/api/ProductApiController.java new file mode 100644 index 000000000..2e2ec7181 --- /dev/null +++ b/src/main/java/gift/web/controller/api/ProductApiController.java @@ -0,0 +1,128 @@ +package gift.web.controller.api; + +import gift.authentication.annotation.LoginMember; +import gift.service.ProductOptionService; +import gift.service.ProductService; +import gift.service.WishProductService; +import gift.web.dto.MemberDetails; +import gift.web.dto.request.product.CreateProductRequest; +import gift.web.dto.request.product.UpdateProductRequest; +import gift.web.dto.request.productoption.CreateProductOptionRequest; +import gift.web.dto.request.productoption.UpdateProductOptionRequest; +import gift.web.dto.request.wishproduct.CreateWishProductRequest; +import gift.web.dto.response.product.CreateProductResponse; +import gift.web.dto.response.product.ReadAllProductsResponse; +import gift.web.dto.response.product.ReadProductResponse; +import gift.web.dto.response.product.UpdateProductResponse; +import gift.web.dto.response.productoption.CreateProductOptionResponse; +import gift.web.dto.response.productoption.ReadAllProductOptionsResponse; +import gift.web.dto.response.productoption.UpdateProductOptionResponse; +import gift.web.dto.response.wishproduct.CreateWishProductResponse; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.NoSuchElementException; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/products") +public class ProductApiController { + + private final ProductService productService; + private final WishProductService wishProductService; + private final ProductOptionService productOptionService; + + public ProductApiController(ProductService productService, WishProductService wishProductService, ProductOptionService productOptionService) { + this.productService = productService; + this.wishProductService = wishProductService; + this.productOptionService = productOptionService; + } + + @GetMapping + public ResponseEntity readAllProducts(@PageableDefault Pageable pageable) { + ReadAllProductsResponse response = productService.readAllProducts(pageable); + return ResponseEntity.ok(response); + } + + @PostMapping + public ResponseEntity createProduct( + @Validated @RequestBody CreateProductRequest request) throws URISyntaxException { + CreateProductResponse response = productService.createProduct(request); + + URI location = new URI("http://localhost:8080/api/products/" + response.getId()); + return ResponseEntity.created(location).body(response); + } + + @GetMapping(params = "categoryId") + public ResponseEntity readProductsByCategoryId(@PageableDefault Pageable pageable, @RequestParam Long categoryId) { + ReadAllProductsResponse response = productService.readProductsByCategoryId(categoryId, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/{productId}") + public ResponseEntity readProduct(@PathVariable Long productId) { + ReadProductResponse response; + response = productService.readProductById(productId); + return ResponseEntity.ok(response); + } + + @PutMapping("/{productId}") + public ResponseEntity updateProduct(@PathVariable Long productId, @Validated @RequestBody UpdateProductRequest request) { + UpdateProductResponse response; + try { + response = productService.updateProduct(productId, request); + } catch (NoSuchElementException e) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{productId}") + public ResponseEntity deleteProduct(@PathVariable Long productId) { + productService.deleteProduct(productId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/wish") + public ResponseEntity createWishProduct(@Validated @RequestBody CreateWishProductRequest request, @LoginMember MemberDetails memberDetails) { + CreateWishProductResponse response = wishProductService.createWishProduct(memberDetails.getId(), request); + return ResponseEntity.ok(response); + } + + @GetMapping("/{productId}/options") + public ResponseEntity readOptions(@PathVariable Long productId) { + ReadAllProductOptionsResponse response = productOptionService.readAllOptions(productId); + return ResponseEntity.ok(response); + } + + @PostMapping("/{productId}/options") + public ResponseEntity createOption(@PathVariable Long productId, @Validated @RequestBody CreateProductOptionRequest request) { + CreateProductOptionResponse response = productOptionService.createOption(productId, request); + + URI location = URI.create("http://localhost:8080/api/products/" + productId + "/options/" + response.getId()); + return ResponseEntity.created(location).body(response); + } + + @PutMapping("/{productId}/options/{optionId}") + public ResponseEntity updateOption(@PathVariable Long productId, @PathVariable Long optionId, @Validated @RequestBody UpdateProductOptionRequest request) { + UpdateProductOptionResponse response = productOptionService.updateOption(optionId, productId, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{productId}/options/{optionId}") + public ResponseEntity deleteOption(@PathVariable Long optionId) { + productOptionService.deleteOption(optionId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gift/web/controller/view/ProductViewController.java b/src/main/java/gift/web/controller/view/ProductViewController.java new file mode 100644 index 000000000..2806f7f5d --- /dev/null +++ b/src/main/java/gift/web/controller/view/ProductViewController.java @@ -0,0 +1,44 @@ +package gift.web.controller.view; + +import gift.service.ProductService; +import gift.web.dto.form.CreateProductForm; +import gift.web.dto.response.product.ReadAllProductsResponse; +import gift.web.dto.response.product.ReadProductResponse; +import java.util.List; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/view/products") +public class ProductViewController { + + private final ProductService productService; + + public ProductViewController(ProductService productService) { + this.productService = productService; + } + + @GetMapping + public String readAdminPage(Model model) { + ReadAllProductsResponse allProductsResponse = productService.readAllProducts(); + List products = allProductsResponse.getProducts(); + model.addAttribute("products", products); + return "admin"; + } + + @GetMapping("/add") + public String addForm(Model model) { + model.addAttribute("product", new CreateProductForm()); + return "form/add-product-form"; + } + + @GetMapping("/{id}") + public String editForm(@PathVariable Long id, Model model) { + ReadProductResponse product = productService.readProductById(id); + model.addAttribute("product", product); + return "form/edit-product-form"; + } +} diff --git a/src/main/java/gift/web/dto/MemberDetails.java b/src/main/java/gift/web/dto/MemberDetails.java new file mode 100644 index 000000000..82b07bdfc --- /dev/null +++ b/src/main/java/gift/web/dto/MemberDetails.java @@ -0,0 +1,27 @@ +package gift.web.dto; + +import gift.domain.Member; +import gift.domain.vo.Email; + +public class MemberDetails { + + private Long id; + private Email email; + + public MemberDetails(Long id, Email email) { + this.id = id; + this.email = email; + } + + public static MemberDetails from(Member member) { + return new MemberDetails(member.getId(), member.getEmail()); + } + + public Long getId() { + return id; + } + + public Email getEmail() { + return email; + } +} diff --git a/src/main/java/gift/web/dto/form/CreateProductForm.java b/src/main/java/gift/web/dto/form/CreateProductForm.java new file mode 100644 index 000000000..7d5a1f6a8 --- /dev/null +++ b/src/main/java/gift/web/dto/form/CreateProductForm.java @@ -0,0 +1,23 @@ +package gift.web.dto.form; + +import java.net.URL; + +public class CreateProductForm { + + private String name; + private Integer price; + private URL imageUrl; + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public URL getImageUrl() { + return imageUrl; + } + +} diff --git a/src/main/java/gift/web/dto/form/UpdateProductForm.java b/src/main/java/gift/web/dto/form/UpdateProductForm.java new file mode 100644 index 000000000..553a9fd29 --- /dev/null +++ b/src/main/java/gift/web/dto/form/UpdateProductForm.java @@ -0,0 +1,40 @@ +package gift.web.dto.form; + +import gift.domain.Product; +import java.net.URL; + +public class UpdateProductForm { + + private final Long id; + private final String name; + private final Integer price; + private final URL imageUrl; + + private UpdateProductForm(Long id, String name, Integer price, URL imageUrl) { + this.id = id; + this.name = name; + this.price = price; + this.imageUrl = imageUrl; + } + + public static UpdateProductForm fromEntity(Product product) { + return new UpdateProductForm(product.getId(), product.getName(), product.getPrice(), product.getImageUrl()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public URL getImageUrl() { + return imageUrl; + } + +} diff --git a/src/main/java/gift/web/dto/request/LoginRequest.java b/src/main/java/gift/web/dto/request/LoginRequest.java new file mode 100644 index 000000000..7a152dbc1 --- /dev/null +++ b/src/main/java/gift/web/dto/request/LoginRequest.java @@ -0,0 +1,23 @@ +package gift.web.dto.request; + +import jakarta.validation.constraints.Email; + +public class LoginRequest { + + @Email + private final String email; + private final String password; + + public LoginRequest(String email, String password) { + this.email = email; + this.password = password; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } +} diff --git a/src/main/java/gift/web/dto/request/category/CreateCategoryRequest.java b/src/main/java/gift/web/dto/request/category/CreateCategoryRequest.java new file mode 100644 index 000000000..704ad4c35 --- /dev/null +++ b/src/main/java/gift/web/dto/request/category/CreateCategoryRequest.java @@ -0,0 +1,60 @@ +package gift.web.dto.request.category; + +import gift.converter.StringToUrlConverter; +import gift.domain.Category; +import gift.domain.vo.Color; +import gift.web.validation.constraints.HexColor; +import gift.web.validation.constraints.SpecialCharacter; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.URL; + +public class CreateCategoryRequest { + + @NotBlank + @Length(min = 1, max = 15) + @SpecialCharacter(allowed = "(, ), [, ], +, -, &, /, _") + private String name; + + @NotBlank + private String description; + + @URL + private String imageUrl; + + @NotBlank + @HexColor + private String color; + + public CreateCategoryRequest(String name, String description, String imageUrl, String color) { + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + this.color = color; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getColor() { + return color; + } + + public Category toEntity() { + return new Category.Builder() + .name(this.name) + .description(this.description) + .imageUrl(StringToUrlConverter.convert(this.imageUrl)) + .color(Color.from(this.color)) + .build(); + } +} diff --git a/src/main/java/gift/web/dto/request/category/UpdateCategoryRequest.java b/src/main/java/gift/web/dto/request/category/UpdateCategoryRequest.java new file mode 100644 index 000000000..c68f2a655 --- /dev/null +++ b/src/main/java/gift/web/dto/request/category/UpdateCategoryRequest.java @@ -0,0 +1,61 @@ +package gift.web.dto.request.category; + +import gift.converter.StringToUrlConverter; +import gift.domain.Category; +import gift.domain.vo.Color; +import gift.web.validation.constraints.HexColor; +import gift.web.validation.constraints.SpecialCharacter; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.URL; + +public class UpdateCategoryRequest { + + @NotBlank + @Length(min = 1, max = 15) + @SpecialCharacter(allowed = "(, ), [, ], +, -, &, /, _") + private String name; + + @NotBlank + private String description; + + @URL + private String imageUrl; + + @NotBlank + @HexColor + private String color; + + public UpdateCategoryRequest(String name, String description, String imageUrl, String color) { + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + this.color = color; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getColor() { + return color; + } + + public Category toEntity() { + return new Category.Builder() + .name(this.name) + .description(this.description) + .imageUrl(StringToUrlConverter.convert(this.imageUrl)) + .color(Color.from(this.color)) + .build(); + } + +} diff --git a/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java b/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java new file mode 100644 index 000000000..6c7d053ba --- /dev/null +++ b/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java @@ -0,0 +1,42 @@ +package gift.web.dto.request.member; + +import gift.domain.Member; +import gift.web.validation.constraints.Password; +import jakarta.validation.constraints.Email; + +public class CreateMemberRequest { + + @Email + private String email; + + @Password + private String password; + + private String name; + + public CreateMemberRequest(String email, String password, String name) { + this.email = email; + this.password = password; + this.name = name; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public Member toEntity() { + return new Member.Builder() + .email(gift.domain.vo.Email.from(this.email)) + .password(gift.domain.vo.Password.from(this.password)) + .name(this.name) + .build(); + } +} diff --git a/src/main/java/gift/web/dto/request/product/CreateProductRequest.java b/src/main/java/gift/web/dto/request/product/CreateProductRequest.java new file mode 100644 index 000000000..a463f3d7d --- /dev/null +++ b/src/main/java/gift/web/dto/request/product/CreateProductRequest.java @@ -0,0 +1,81 @@ +package gift.web.dto.request.product; + +import gift.converter.StringToUrlConverter; +import gift.domain.Category; +import gift.domain.Product; +import gift.domain.ProductOption; +import gift.web.dto.request.productoption.CreateProductOptionRequest; +import gift.web.validation.constraints.RequiredKakaoApproval; +import gift.web.validation.constraints.SpecialCharacter; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; +import org.hibernate.validator.constraints.URL; + +public class CreateProductRequest { + + @NotBlank + @Length(min = 1, max = 15) + @SpecialCharacter(allowed = "(, ), [, ], +, -, &, /, _") + @RequiredKakaoApproval + private final String name; + + @Range(min = 1000, max = 10000000) + private final Integer price; + + @URL + private final String imageUrl; + + @NotNull + private final Long categoryId; + + @Valid + @NotEmpty + private final List productOptions; + + public CreateProductRequest(String name, Integer price, String imageUrl, Long categoryId, List productOptions) { + this.name = name; + this.price = price; + this.imageUrl = imageUrl; + this.categoryId = categoryId; + this.productOptions = productOptions; + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public String getImageUrl() { + return imageUrl; + } + + public Long getCategoryId() { + return categoryId; + } + + public List getProductOptions() { + return productOptions; + } + + public Product toEntity(Category category) { + List productOptions = this.productOptions.stream() + .map(CreateProductOptionRequest::toEntity) + .toList(); + + return new Product.Builder() + .name(this.name) + .price(this.price) + .imageUrl(StringToUrlConverter.convert(this.imageUrl)) + .category(category) + .productOptions(productOptions) + .build(); + } +} diff --git a/src/main/java/gift/web/dto/request/product/UpdateProductRequest.java b/src/main/java/gift/web/dto/request/product/UpdateProductRequest.java new file mode 100644 index 000000000..d74ee114d --- /dev/null +++ b/src/main/java/gift/web/dto/request/product/UpdateProductRequest.java @@ -0,0 +1,50 @@ +package gift.web.dto.request.product; + +import gift.converter.StringToUrlConverter; +import gift.domain.Product; +import gift.web.validation.constraints.RequiredKakaoApproval; +import gift.web.validation.constraints.SpecialCharacter; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; +import org.hibernate.validator.constraints.URL; + +public class UpdateProductRequest { + + @NotBlank + @Length(min = 1, max = 15) + @SpecialCharacter(allowed = "(, ), [, ], +, -, &, /, _") + @RequiredKakaoApproval + private final String name; + @Range(min = 1000, max = 10000000) + private final Integer price; + + @URL + private final String imageUrl; + + public UpdateProductRequest(String name, Integer price, String imageUrl) { + this.name = name; + this.price = price; + this.imageUrl = imageUrl; + } + + public Product toEntity() { + return new Product.Builder() + .name(name) + .price(price) + .imageUrl(StringToUrlConverter.convert(imageUrl)) + .build(); + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public String getImageUrl() { + return imageUrl; + } +} diff --git a/src/main/java/gift/web/dto/request/productoption/CreateProductOptionRequest.java b/src/main/java/gift/web/dto/request/productoption/CreateProductOptionRequest.java new file mode 100644 index 000000000..43c33b6e2 --- /dev/null +++ b/src/main/java/gift/web/dto/request/productoption/CreateProductOptionRequest.java @@ -0,0 +1,47 @@ +package gift.web.dto.request.productoption; + +import gift.domain.ProductOption; +import gift.web.validation.constraints.SpecialCharacter; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; + +public class CreateProductOptionRequest { + + @NotBlank + @Length(min = 1, max = 50) + @SpecialCharacter(allowed = "(, ), [, ], +, -, &, /, _") + private String name; + + @Range(min = 1, max = 100_000_000) + private Integer stock; + + public CreateProductOptionRequest(String name, Integer stock) { + this.name = name; + this.stock = stock; + } + + public ProductOption toEntity() { + return new ProductOption.Builder() + .name(this.name) + .stock(this.stock) + .build(); + } + + public ProductOption toEntity(Long productId) { + return new ProductOption.Builder() + .name(this.name) + .stock(this.stock) + .productId(productId) + .build(); + } + + public String getName() { + return name; + } + + public Integer getStock() { + return stock; + } + +} diff --git a/src/main/java/gift/web/dto/request/productoption/SubtractProductOptionQuantityRequest.java b/src/main/java/gift/web/dto/request/productoption/SubtractProductOptionQuantityRequest.java new file mode 100644 index 000000000..efd9bb113 --- /dev/null +++ b/src/main/java/gift/web/dto/request/productoption/SubtractProductOptionQuantityRequest.java @@ -0,0 +1,14 @@ +package gift.web.dto.request.productoption; + +public class SubtractProductOptionQuantityRequest { + + private final Integer quantity; + + public SubtractProductOptionQuantityRequest(Integer quantity) { + this.quantity = quantity; + } + + public Integer getQuantity() { + return quantity; + } +} diff --git a/src/main/java/gift/web/dto/request/productoption/UpdateProductOptionRequest.java b/src/main/java/gift/web/dto/request/productoption/UpdateProductOptionRequest.java new file mode 100644 index 000000000..55907e08d --- /dev/null +++ b/src/main/java/gift/web/dto/request/productoption/UpdateProductOptionRequest.java @@ -0,0 +1,39 @@ +package gift.web.dto.request.productoption; + +import gift.domain.ProductOption; +import gift.web.validation.constraints.SpecialCharacter; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; + +public class UpdateProductOptionRequest { + + @NotBlank + @Length(min = 1, max = 50) + @SpecialCharacter(allowed = "(, ), [, ], +, -, &, /, _") + private String name; + + @Range(min = 1, max = 100_000_000) + private Integer stock; + + public UpdateProductOptionRequest(String name, Integer stock) { + this.name = name; + this.stock = stock; + } + + public ProductOption toEntity() { + return new ProductOption.Builder() + .name(name) + .stock(stock) + .build(); + } + + public String getName() { + return name; + } + + public Integer getStock() { + return stock; + } + +} diff --git a/src/main/java/gift/web/dto/request/wishproduct/CreateWishProductRequest.java b/src/main/java/gift/web/dto/request/wishproduct/CreateWishProductRequest.java new file mode 100644 index 000000000..f7e48f865 --- /dev/null +++ b/src/main/java/gift/web/dto/request/wishproduct/CreateWishProductRequest.java @@ -0,0 +1,25 @@ +package gift.web.dto.request.wishproduct; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public class CreateWishProductRequest { + + @NotNull + private Long productId; + @Min(1) + private Integer quantity; + + public CreateWishProductRequest(Long productId, Integer quantity) { + this.productId = productId; + this.quantity = quantity; + } + + public Long getProductId() { + return productId; + } + + public Integer getQuantity() { + return quantity; + } +} diff --git a/src/main/java/gift/web/dto/request/wishproduct/UpdateWishProductRequest.java b/src/main/java/gift/web/dto/request/wishproduct/UpdateWishProductRequest.java new file mode 100644 index 000000000..28678565a --- /dev/null +++ b/src/main/java/gift/web/dto/request/wishproduct/UpdateWishProductRequest.java @@ -0,0 +1,20 @@ +package gift.web.dto.request.wishproduct; + +import jakarta.validation.constraints.Min; + +public class UpdateWishProductRequest { + + @Min(1) + private Integer quantity; + + private UpdateWishProductRequest() { + } + + public UpdateWishProductRequest(Integer quantity) { + this.quantity = quantity; + } + + public Integer getQuantity() { + return quantity; + } +} diff --git a/src/main/java/gift/web/dto/response/ErrorResponse.java b/src/main/java/gift/web/dto/response/ErrorResponse.java new file mode 100644 index 000000000..b755f460a --- /dev/null +++ b/src/main/java/gift/web/dto/response/ErrorResponse.java @@ -0,0 +1,49 @@ +package gift.web.dto.response; + +import gift.web.validation.exception.CustomException; +import gift.web.validation.exception.code.ErrorStatus; +import java.time.LocalDateTime; +import org.springframework.validation.BindingResult; + +public class ErrorResponse { + + private int code; + private String category; + private String description; + private LocalDateTime timestamp; + + public ErrorResponse(int code, String category, String description) { + this.code = code; + this.category = category; + this.description = description; + this.timestamp = LocalDateTime.now(); + } + + public static ErrorResponse from(CustomException exception) { + return new ErrorResponse(exception.getErrorStatus().getCode(), exception.getErrorStatus().getCategory().getDescription(), exception.getMessage()); + } + + public static ErrorResponse from(BindingResult bindingResult) { + return new ErrorResponse(-40010, "INVALID_PARAMETER", bindingResult.getFieldError().getDefaultMessage()); + } + + public static ErrorResponse of(ErrorStatus errorStatus, String description) { + return new ErrorResponse(errorStatus.getCode(), errorStatus.getCategory().getDescription(), description); + } + + public int getCode() { + return code; + } + + public String getCategory() { + return category; + } + + public String getDescription() { + return description; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } +} diff --git a/src/main/java/gift/web/dto/response/LoginResponse.java b/src/main/java/gift/web/dto/response/LoginResponse.java new file mode 100644 index 000000000..bdb8b444a --- /dev/null +++ b/src/main/java/gift/web/dto/response/LoginResponse.java @@ -0,0 +1,19 @@ +package gift.web.dto.response; + +import gift.authentication.token.Token; + +public class LoginResponse { + + private Token token; + + private LoginResponse() { + } + + public LoginResponse(Token token) { + this.token = token; + } + + public Token getToken() { + return token; + } +} diff --git a/src/main/java/gift/web/dto/response/category/CreateCategoryResponse.java b/src/main/java/gift/web/dto/response/category/CreateCategoryResponse.java new file mode 100644 index 000000000..7421bb3bb --- /dev/null +++ b/src/main/java/gift/web/dto/response/category/CreateCategoryResponse.java @@ -0,0 +1,47 @@ +package gift.web.dto.response.category; + +import gift.domain.Category; + +public class CreateCategoryResponse { + + private final Long id; + private final String name; + private final String description; + private final String imageUrl; + private final String color; + + public CreateCategoryResponse(Long id, String name, String description, String imageUrl, + String color) { + this.id = id; + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + this.color = color; + } + + public static CreateCategoryResponse fromEntity(Category category) { + return new CreateCategoryResponse( + category.getId(), category.getName(), category.getDescription(), + category.getImageUrl().toString(), category.getColor().toString()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getColor() { + return color; + } +} diff --git a/src/main/java/gift/web/dto/response/category/ReadAllCategoriesResponse.java b/src/main/java/gift/web/dto/response/category/ReadAllCategoriesResponse.java new file mode 100644 index 000000000..9825237f3 --- /dev/null +++ b/src/main/java/gift/web/dto/response/category/ReadAllCategoriesResponse.java @@ -0,0 +1,16 @@ +package gift.web.dto.response.category; + +import java.util.List; + +public class ReadAllCategoriesResponse { + + private final List categories; + + public ReadAllCategoriesResponse(List categories) { + this.categories = categories; + } + + public List getCategories() { + return categories; + } +} diff --git a/src/main/java/gift/web/dto/response/category/ReadCategoryResponse.java b/src/main/java/gift/web/dto/response/category/ReadCategoryResponse.java new file mode 100644 index 000000000..a6736c6d9 --- /dev/null +++ b/src/main/java/gift/web/dto/response/category/ReadCategoryResponse.java @@ -0,0 +1,47 @@ +package gift.web.dto.response.category; + +import gift.domain.Category; + +public class ReadCategoryResponse { + + private final Long id; + private final String name; + private final String description; + private final String imageUrl; + private final String color; + + public ReadCategoryResponse(Long id, String name, String description, String imageUrl, + String color) { + this.id = id; + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + this.color = color; + } + + public static ReadCategoryResponse fromEntity(Category category) { + return new ReadCategoryResponse( + category.getId(), category.getName(), category.getDescription(), + category.getImageUrl().toString(), category.getColor().toString()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getColor() { + return color; + } +} diff --git a/src/main/java/gift/web/dto/response/category/UpdateCategoryResponse.java b/src/main/java/gift/web/dto/response/category/UpdateCategoryResponse.java new file mode 100644 index 000000000..30fe3c520 --- /dev/null +++ b/src/main/java/gift/web/dto/response/category/UpdateCategoryResponse.java @@ -0,0 +1,47 @@ +package gift.web.dto.response.category; + +import gift.domain.Category; + +public class UpdateCategoryResponse { + + private final Long id; + private final String name; + private final String description; + private final String imageUrl; + private final String color; + + public UpdateCategoryResponse(Long id, String name, String description, String imageUrl, + String color) { + this.id = id; + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + this.color = color; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getColor() { + return color; + } + + public static UpdateCategoryResponse fromEntity(Category category) { + return new UpdateCategoryResponse( + category.getId(), category.getName(), category.getDescription(), + category.getImageUrl().toString(), category.getColor().toString()); + } +} diff --git a/src/main/java/gift/web/dto/response/member/CreateMemberResponse.java b/src/main/java/gift/web/dto/response/member/CreateMemberResponse.java new file mode 100644 index 000000000..f5cf0a81a --- /dev/null +++ b/src/main/java/gift/web/dto/response/member/CreateMemberResponse.java @@ -0,0 +1,34 @@ +package gift.web.dto.response.member; + +import gift.domain.Member; + +public class CreateMemberResponse { + + private Long id; + + private String email; + + private String name; + + public CreateMemberResponse(Long id, String email, String name) { + this.id = id; + this.email = email; + this.name = name; + } + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getName() { + return name; + } + + public static CreateMemberResponse fromEntity(Member member) { + return new CreateMemberResponse(member.getId(), member.getEmail().getValue(), member.getName()); + } +} diff --git a/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java b/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java new file mode 100644 index 000000000..adfd869fd --- /dev/null +++ b/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java @@ -0,0 +1,39 @@ +package gift.web.dto.response.member; + +import gift.domain.Member; + +public class ReadMemberResponse { + + private Long id; + private String email; + private String password; + private String name; + + private ReadMemberResponse(Long id, String email, String password, String name) { + this.id = id; + this.email = email; + this.password = password; + this.name = name; + } + + public static ReadMemberResponse fromEntity(Member member) { + return new ReadMemberResponse(member.getId(), member.getEmail().getValue(), member.getPassword().getValue(), + member.getName()); + } + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/gift/web/dto/response/product/CreateProductResponse.java b/src/main/java/gift/web/dto/response/product/CreateProductResponse.java new file mode 100644 index 000000000..356b495d3 --- /dev/null +++ b/src/main/java/gift/web/dto/response/product/CreateProductResponse.java @@ -0,0 +1,52 @@ +package gift.web.dto.response.product; + +import gift.domain.Product; +import gift.domain.ProductOption; +import java.util.List; + +public class CreateProductResponse { + + private final Long id; + + private final String name; + + private final Integer price; + + private final String imageUrl; + + private final List options; + + public CreateProductResponse(Long id, String name, Integer price, String imageUrl, + List options) { + this.id = id; + this.name = name; + this.price = price; + this.imageUrl = imageUrl; + this.options = options; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public String getImageUrl() { + return imageUrl; + } + + public List getOptions() { + return options; + } + + public static CreateProductResponse fromEntity(Product product) { + return new CreateProductResponse(product.getId(), product.getName(), product.getPrice(), + product.getImageUrl().toString(), product.getProductOptions()); + } +} diff --git a/src/main/java/gift/web/dto/response/product/ReadAllProductsResponse.java b/src/main/java/gift/web/dto/response/product/ReadAllProductsResponse.java new file mode 100644 index 000000000..3cee01800 --- /dev/null +++ b/src/main/java/gift/web/dto/response/product/ReadAllProductsResponse.java @@ -0,0 +1,20 @@ +package gift.web.dto.response.product; + +import java.util.List; + +public class ReadAllProductsResponse { + + private List products; + + private ReadAllProductsResponse(List products) { + this.products = products; + } + + public static ReadAllProductsResponse from(List products) { + return new ReadAllProductsResponse(products); + } + + public List getProducts() { + return products; + } +} diff --git a/src/main/java/gift/web/dto/response/product/ReadProductResponse.java b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java new file mode 100644 index 000000000..48ba51892 --- /dev/null +++ b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java @@ -0,0 +1,47 @@ +package gift.web.dto.response.product; + +import gift.domain.Product; +import gift.web.dto.response.category.ReadCategoryResponse; + +public class ReadProductResponse { + + private final Long id; + private final String name; + private final Integer price; + private final String imageUrl; + private final ReadCategoryResponse category; + + private ReadProductResponse(Long id, String name, Integer price, String imageUrl, + ReadCategoryResponse category) { + this.id = id; + this.name = name; + this.price = price; + this.imageUrl = imageUrl; + this.category = category; + } + + public static ReadProductResponse fromEntity(Product product) { + return new ReadProductResponse(product.getId(), product.getName(), product.getPrice(), product.getImageUrl().toString(), ReadCategoryResponse.fromEntity(product.getCategory())); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public String getImageUrl() { + return imageUrl; + } + + public ReadCategoryResponse getCategory() { + return category; + } + +} diff --git a/src/main/java/gift/web/dto/response/product/UpdateProductResponse.java b/src/main/java/gift/web/dto/response/product/UpdateProductResponse.java new file mode 100644 index 000000000..d4f66fb3f --- /dev/null +++ b/src/main/java/gift/web/dto/response/product/UpdateProductResponse.java @@ -0,0 +1,47 @@ +package gift.web.dto.response.product; + +import gift.domain.Product; +import gift.web.dto.response.category.ReadCategoryResponse; + +public class UpdateProductResponse { + + private final Long id; + private final String name; + private final Integer price; + private final String imageUrl; + private final ReadCategoryResponse category; + + private UpdateProductResponse(Long id, String name, Integer price, String imageUrl, + ReadCategoryResponse category) { + this.id = id; + this.name = name; + this.price = price; + this.imageUrl = imageUrl; + this.category = category; + } + + public static UpdateProductResponse from(Product product) { + return new UpdateProductResponse(product.getId(), product.getName(), product.getPrice(), + product.getImageUrl().toString(), ReadCategoryResponse.fromEntity(product.getCategory())); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public String getImageUrl() { + return imageUrl; + } + + public ReadCategoryResponse getCategory() { + return category; + } +} diff --git a/src/main/java/gift/web/dto/response/productoption/CreateProductOptionResponse.java b/src/main/java/gift/web/dto/response/productoption/CreateProductOptionResponse.java new file mode 100644 index 000000000..3a0b596f7 --- /dev/null +++ b/src/main/java/gift/web/dto/response/productoption/CreateProductOptionResponse.java @@ -0,0 +1,37 @@ +package gift.web.dto.response.productoption; + +import gift.domain.ProductOption; + +public class CreateProductOptionResponse { + + private final Long id; + private final String name; + private final Integer stock; + + public CreateProductOptionResponse(Long id, String name, Integer stock) { + this.id = id; + this.name = name; + this.stock = stock; + } + + public static CreateProductOptionResponse fromEntity(ProductOption productOption) { + return new CreateProductOptionResponse( + productOption.getId(), + productOption.getName(), + productOption.getStock() + ); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Integer getStock() { + return stock; + } + +} diff --git a/src/main/java/gift/web/dto/response/productoption/ReadAllProductOptionsResponse.java b/src/main/java/gift/web/dto/response/productoption/ReadAllProductOptionsResponse.java new file mode 100644 index 000000000..baee4bdb5 --- /dev/null +++ b/src/main/java/gift/web/dto/response/productoption/ReadAllProductOptionsResponse.java @@ -0,0 +1,20 @@ +package gift.web.dto.response.productoption; + +import java.util.List; + +public class ReadAllProductOptionsResponse { + + List options; + + public ReadAllProductOptionsResponse(List options) { + this.options = options; + } + + public static ReadAllProductOptionsResponse from(List options) { + return new ReadAllProductOptionsResponse(options); + } + + public List getOptions() { + return options; + } +} diff --git a/src/main/java/gift/web/dto/response/productoption/ReadProductOptionResponse.java b/src/main/java/gift/web/dto/response/productoption/ReadProductOptionResponse.java new file mode 100644 index 000000000..2d056b5ea --- /dev/null +++ b/src/main/java/gift/web/dto/response/productoption/ReadProductOptionResponse.java @@ -0,0 +1,34 @@ +package gift.web.dto.response.productoption; + +import gift.domain.ProductOption; + +public class ReadProductOptionResponse { + + private final Long id; + + private final String name; + + private final Integer stock; + + public ReadProductOptionResponse(Long id, String name, Integer stock) { + this.id = id; + this.name = name; + this.stock = stock; + } + + public static ReadProductOptionResponse fromEntity(ProductOption productOption) { + return new ReadProductOptionResponse(productOption.getId(), productOption.getName(), productOption.getStock()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Integer getStock() { + return stock; + } +} diff --git a/src/main/java/gift/web/dto/response/productoption/SubtractProductOptionQuantityResponse.java b/src/main/java/gift/web/dto/response/productoption/SubtractProductOptionQuantityResponse.java new file mode 100644 index 000000000..1fcb1b0d6 --- /dev/null +++ b/src/main/java/gift/web/dto/response/productoption/SubtractProductOptionQuantityResponse.java @@ -0,0 +1,34 @@ +package gift.web.dto.response.productoption; + +import gift.domain.ProductOption; + +public class SubtractProductOptionQuantityResponse { + + private final Long id; + + private final String name; + + private final Integer stock; + + public SubtractProductOptionQuantityResponse(Long id, String name, Integer stock) { + this.id = id; + this.name = name; + this.stock = stock; + } + + public static SubtractProductOptionQuantityResponse fromEntity(ProductOption productOption) { + return new SubtractProductOptionQuantityResponse(productOption.getId(), productOption.getName(), productOption.getStock()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Integer getStock() { + return stock; + } +} diff --git a/src/main/java/gift/web/dto/response/productoption/UpdateProductOptionResponse.java b/src/main/java/gift/web/dto/response/productoption/UpdateProductOptionResponse.java new file mode 100644 index 000000000..37087f9a4 --- /dev/null +++ b/src/main/java/gift/web/dto/response/productoption/UpdateProductOptionResponse.java @@ -0,0 +1,33 @@ +package gift.web.dto.response.productoption; + +import gift.domain.ProductOption; + +public class UpdateProductOptionResponse { + + private Long id; + private String name; + private Integer stock; + + public UpdateProductOptionResponse(Long id, String name, Integer stock) { + this.id = id; + this.name = name; + this.stock = stock; + } + + public static UpdateProductOptionResponse fromEntity(ProductOption option) { + return new UpdateProductOptionResponse(option.getId(), option.getName(), option.getStock()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Integer getStock() { + return stock; + } + +} diff --git a/src/main/java/gift/web/dto/response/wishproduct/CreateWishProductResponse.java b/src/main/java/gift/web/dto/response/wishproduct/CreateWishProductResponse.java new file mode 100644 index 000000000..2a577dbf6 --- /dev/null +++ b/src/main/java/gift/web/dto/response/wishproduct/CreateWishProductResponse.java @@ -0,0 +1,27 @@ +package gift.web.dto.response.wishproduct; + +import gift.domain.WishProduct; + +public class CreateWishProductResponse { + + private final Long id; + + private final Integer quantity; + + public CreateWishProductResponse(Long id, Integer quantity) { + this.id = id; + this.quantity = quantity; + } + + public static CreateWishProductResponse fromEntity(WishProduct wishProduct) { + return new CreateWishProductResponse(wishProduct.getId(), wishProduct.getQuantity()); + } + + public Long getId() { + return id; + } + + public Integer getQuantity() { + return quantity; + } +} diff --git a/src/main/java/gift/web/dto/response/wishproduct/ReadAllWishProductsResponse.java b/src/main/java/gift/web/dto/response/wishproduct/ReadAllWishProductsResponse.java new file mode 100644 index 000000000..d9d607963 --- /dev/null +++ b/src/main/java/gift/web/dto/response/wishproduct/ReadAllWishProductsResponse.java @@ -0,0 +1,19 @@ +package gift.web.dto.response.wishproduct; + +import java.util.List; + +public class ReadAllWishProductsResponse { + + private List wishlist; + + private ReadAllWishProductsResponse() { + } + + public ReadAllWishProductsResponse(List wishlist) { + this.wishlist = wishlist; + } + + public List getWishlist() { + return wishlist; + } +} diff --git a/src/main/java/gift/web/dto/response/wishproduct/ReadWishProductResponse.java b/src/main/java/gift/web/dto/response/wishproduct/ReadWishProductResponse.java new file mode 100644 index 000000000..3cb0a10dc --- /dev/null +++ b/src/main/java/gift/web/dto/response/wishproduct/ReadWishProductResponse.java @@ -0,0 +1,75 @@ +package gift.web.dto.response.wishproduct; + +import gift.domain.Product; +import gift.domain.WishProduct; +import java.util.Objects; + +public class ReadWishProductResponse { + + private final Long id; + private final Long productId; + private final String name; + private final Integer price; + private final Integer quantity; + private final String imageUrl; + + public ReadWishProductResponse(Long id, Long productId, String name, Integer price, + Integer quantity, String imageUrl) { + this.id = id; + this.productId = productId; + this.name = name; + this.price = price; + this.quantity = quantity; + this.imageUrl = imageUrl; + } + + public static ReadWishProductResponse fromEntity(WishProduct wishProduct) { + Product product = wishProduct.getProduct(); + return new ReadWishProductResponse(wishProduct.getId(), product.getId(), product.getName(), product.getPrice(), + wishProduct.getQuantity(), product.getImageUrl().toString()); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public Integer getQuantity() { + return quantity; + } + + public String getImageUrl() { + return imageUrl; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ReadWishProductResponse that = (ReadWishProductResponse) o; + return Objects.equals(id, that.id) && Objects.equals(productId, + that.productId) && Objects.equals(name, that.name) && Objects.equals( + price, that.price) && Objects.equals(quantity, that.quantity) + && Objects.equals(imageUrl, that.imageUrl); + } + + @Override + public int hashCode() { + return Objects.hash(id, productId, name, price, quantity, imageUrl); + } +} diff --git a/src/main/java/gift/web/dto/response/wishproduct/UpdateWishProductResponse.java b/src/main/java/gift/web/dto/response/wishproduct/UpdateWishProductResponse.java new file mode 100644 index 000000000..dfd1b068c --- /dev/null +++ b/src/main/java/gift/web/dto/response/wishproduct/UpdateWishProductResponse.java @@ -0,0 +1,53 @@ +package gift.web.dto.response.wishproduct; + +import gift.domain.Product; +import gift.domain.WishProduct; + +public class UpdateWishProductResponse { + + private final Long id; + private final Long productId; + private final String name; + private final Integer price; + private final Integer quantity; + private final String imageUrl; + + public UpdateWishProductResponse(Long id, Long productId, String name, Integer price, Integer quantity, String imageUrl) { + this.id = id; + this.productId = productId; + this.name = name; + this.price = price; + this.quantity = quantity; + this.imageUrl = imageUrl; + } + + public static UpdateWishProductResponse fromEntity(WishProduct wishProduct) { + Product product = wishProduct.getProduct(); + return new UpdateWishProductResponse(wishProduct.getId(), product.getId(), product.getName(), + product.getPrice(), wishProduct.getQuantity(), product.getImageUrl().toString()); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public String getName() { + return name; + } + + public Integer getPrice() { + return price; + } + + public Integer getQuantity() { + return quantity; + } + + public String getImageUrl() { + return imageUrl; + } +} diff --git a/src/main/java/gift/web/resolver/LoginMemberArgumentResolver.java b/src/main/java/gift/web/resolver/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..9935c1f56 --- /dev/null +++ b/src/main/java/gift/web/resolver/LoginMemberArgumentResolver.java @@ -0,0 +1,53 @@ +package gift.web.resolver; + +import gift.authentication.annotation.LoginMember; +import gift.authentication.token.JwtResolver; +import gift.authentication.token.Token; +import gift.service.MemberDetailsService; +import gift.web.dto.MemberDetails; +import gift.web.validation.exception.client.InvalidCredentialsException; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final JwtResolver jwtResolver; + private final MemberDetailsService memberDetailsService; + private final String AUTHORIZATION_HEADER = "Authorization"; + + + public LoginMemberArgumentResolver(JwtResolver jwtResolver, MemberDetailsService memberDetailsService) { + this.jwtResolver = jwtResolver; + this.memberDetailsService = memberDetailsService; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasParameterAnnotation = parameter.hasParameterAnnotation(LoginMember.class); + boolean isAssignable = MemberDetails.class.isAssignableFrom(parameter.getParameterType()); + return hasParameterAnnotation && isAssignable; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + + String authorization = webRequest.getHeader(AUTHORIZATION_HEADER); + Token token = Token.from(extractToken(authorization)); + + Long memberId = jwtResolver.resolveId(token) + .orElseThrow(InvalidCredentialsException::new); + + return memberDetailsService.loadUserById(memberId); + } + + private String extractToken(String Authorization) { + return Authorization.substring(7); + } + +} diff --git a/src/main/java/gift/web/validation/constraints/HexColor.java b/src/main/java/gift/web/validation/constraints/HexColor.java new file mode 100644 index 000000000..7274c42e2 --- /dev/null +++ b/src/main/java/gift/web/validation/constraints/HexColor.java @@ -0,0 +1,22 @@ +package gift.web.validation.constraints; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import gift.web.validation.validator.HexColorValidator; +import jakarta.validation.Constraint; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RUNTIME) +@Constraint(validatedBy = HexColorValidator.class) +public @interface HexColor { + + String message() default "올바른 색상 코드를 입력해주세요."; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/gift/web/validation/constraints/Password.java b/src/main/java/gift/web/validation/constraints/Password.java new file mode 100644 index 000000000..ed4da39db --- /dev/null +++ b/src/main/java/gift/web/validation/constraints/Password.java @@ -0,0 +1,22 @@ +package gift.web.validation.constraints; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import gift.web.validation.validator.PasswordValidator; +import jakarta.validation.Constraint; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RUNTIME) +@Constraint(validatedBy = PasswordValidator.class) +public @interface Password { + + String message() default "비밀번호는 영문 대소문자, 숫자를 포함하여 8자 이상 15자 이하로 입력해주세요."; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/gift/web/validation/constraints/RequiredKakaoApproval.java b/src/main/java/gift/web/validation/constraints/RequiredKakaoApproval.java new file mode 100644 index 000000000..58715136d --- /dev/null +++ b/src/main/java/gift/web/validation/constraints/RequiredKakaoApproval.java @@ -0,0 +1,22 @@ +package gift.web.validation.constraints; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import gift.web.validation.validator.KakaoApprovalValidator; +import jakarta.validation.Constraint; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RUNTIME) +@Constraint(validatedBy = KakaoApprovalValidator.class) +public @interface RequiredKakaoApproval { + + String message() default "담당 MD와 협의한 경우에만 사용가능한 키워드가 존재합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/gift/web/validation/constraints/SpecialCharacter.java b/src/main/java/gift/web/validation/constraints/SpecialCharacter.java new file mode 100644 index 000000000..fccf22447 --- /dev/null +++ b/src/main/java/gift/web/validation/constraints/SpecialCharacter.java @@ -0,0 +1,24 @@ +package gift.web.validation.constraints; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import gift.web.validation.validator.SpecialCharacterValidator; +import jakarta.validation.Constraint; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RUNTIME) +@Constraint(validatedBy = SpecialCharacterValidator.class) +public @interface SpecialCharacter { + + String allowed(); + + String message() default "'(', ')', '[', ']', '+', '-', '&', '/', '_' 외의 특수문자는 사용할 수 없습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/gift/web/validation/exception/CustomException.java b/src/main/java/gift/web/validation/exception/CustomException.java new file mode 100644 index 000000000..8f32bb4d6 --- /dev/null +++ b/src/main/java/gift/web/validation/exception/CustomException.java @@ -0,0 +1,32 @@ +package gift.web.validation.exception; + +import gift.web.validation.exception.code.ErrorStatus; + +/** + * 커스텀 예외를 정의하고자 하는 경우 상속하여야 한다. + */ +public abstract class CustomException extends RuntimeException { + + protected CustomException() { + super(); + } + + protected CustomException(String message) { + super(message); + } + + protected CustomException(String message, Throwable cause) { + super(message, cause); + } + + protected CustomException(Throwable cause) { + super(cause); + } + + /** + * 예외가 어떤 {@link ErrorStatus} 를 가지는지 반환한다. + * @return {@link ErrorStatus} + */ + public abstract ErrorStatus getErrorStatus(); + +} \ No newline at end of file diff --git a/src/main/java/gift/web/validation/exception/client/AlreadyExistsException.java b/src/main/java/gift/web/validation/exception/client/AlreadyExistsException.java new file mode 100644 index 000000000..aef6ad6e0 --- /dev/null +++ b/src/main/java/gift/web/validation/exception/client/AlreadyExistsException.java @@ -0,0 +1,28 @@ +package gift.web.validation.exception.client; + +import static gift.web.validation.exception.code.ErrorStatus.ALREADY_EXISTS; + +import gift.web.validation.exception.CustomException; +import gift.web.validation.exception.code.ErrorStatus; + +public class AlreadyExistsException extends CustomException { + + private static final String ERROR_MESSAGE = "이미 존재하는 자원입니다. 중복 값: %s"; + + public AlreadyExistsException(String resource) { + super(ERROR_MESSAGE.formatted(resource)); + } + + public AlreadyExistsException(String resource, Throwable cause) { + super(ERROR_MESSAGE.formatted(resource), cause); + } + + public AlreadyExistsException(Throwable cause) { + super(cause); + } + + @Override + public ErrorStatus getErrorStatus() { + return ALREADY_EXISTS; + } +} diff --git a/src/main/java/gift/web/validation/exception/client/BadRequestException.java b/src/main/java/gift/web/validation/exception/client/BadRequestException.java new file mode 100644 index 000000000..5b65b0f1d --- /dev/null +++ b/src/main/java/gift/web/validation/exception/client/BadRequestException.java @@ -0,0 +1,32 @@ +package gift.web.validation.exception.client; + +import static gift.web.validation.exception.code.ErrorStatus.BAD_REQUEST; + +import gift.web.validation.exception.CustomException; +import gift.web.validation.exception.code.ErrorStatus; + +/** + * 4XX 예외 + */ +public class BadRequestException extends CustomException { + + private static final String ERROR_MESSAGE = "잘못된 요청입니다. 요청 URL: %s"; + + public BadRequestException(String url) { + super(ERROR_MESSAGE.formatted(url)); + } + + public BadRequestException(String url, Throwable cause) { + super(ERROR_MESSAGE.formatted(url), cause); + } + + public BadRequestException(Throwable cause) { + super(cause); + } + + @Override + public ErrorStatus getErrorStatus() { + return BAD_REQUEST; + } + +} diff --git a/src/main/java/gift/web/validation/exception/client/IncorrectEmailException.java b/src/main/java/gift/web/validation/exception/client/IncorrectEmailException.java new file mode 100644 index 000000000..ea04b176e --- /dev/null +++ b/src/main/java/gift/web/validation/exception/client/IncorrectEmailException.java @@ -0,0 +1,29 @@ +package gift.web.validation.exception.client; + +import static gift.web.validation.exception.code.ErrorStatus.INCORRECT_EMAIL; + +import gift.web.validation.exception.CustomException; +import gift.web.validation.exception.code.ErrorStatus; + +public class IncorrectEmailException extends CustomException { + + private static final String ERROR_MESSAGE = "%s 는 잘못된 이메일입니다."; + + public IncorrectEmailException(String email) { + super(ERROR_MESSAGE.formatted(email)); + } + + public IncorrectEmailException(String email, Throwable cause) { + super(ERROR_MESSAGE.formatted(email), cause); + } + + public IncorrectEmailException(Throwable cause) { + super(cause); + } + + @Override + public ErrorStatus getErrorStatus() { + return INCORRECT_EMAIL; + } + +} diff --git a/src/main/java/gift/web/validation/exception/client/IncorrectPasswordException.java b/src/main/java/gift/web/validation/exception/client/IncorrectPasswordException.java new file mode 100644 index 000000000..b97c35370 --- /dev/null +++ b/src/main/java/gift/web/validation/exception/client/IncorrectPasswordException.java @@ -0,0 +1,33 @@ +package gift.web.validation.exception.client; + +import static gift.web.validation.exception.code.ErrorStatus.INCORRECT_PASSWORD; + +import gift.web.validation.exception.CustomException; +import gift.web.validation.exception.code.ErrorStatus; + +public class IncorrectPasswordException extends CustomException { + + private static final String ERROR_MESSAGE = "비밀번호가 일치하지 않습니다."; + + public IncorrectPasswordException() { + super(ERROR_MESSAGE); + } + + public IncorrectPasswordException(String message) { + super(message); + } + + public IncorrectPasswordException(String message, Throwable cause) { + super(message, cause); + } + + public IncorrectPasswordException(Throwable cause) { + super(cause); + } + + @Override + public ErrorStatus getErrorStatus() { + return INCORRECT_PASSWORD; + } + +} diff --git a/src/main/java/gift/web/validation/exception/client/InvalidCredentialsException.java b/src/main/java/gift/web/validation/exception/client/InvalidCredentialsException.java new file mode 100644 index 000000000..49cb645bf --- /dev/null +++ b/src/main/java/gift/web/validation/exception/client/InvalidCredentialsException.java @@ -0,0 +1,33 @@ +package gift.web.validation.exception.client; + +import static gift.web.validation.exception.code.ErrorStatus.UNAUTHORIZED_INVALID_CREDENTIALS; + +import gift.web.validation.exception.CustomException; +import gift.web.validation.exception.code.ErrorStatus; + +public class InvalidCredentialsException extends CustomException { + + private static final String ERROR_MESSAGE = "유효하지 않은 신원 정보입니다."; + + public InvalidCredentialsException() { + super(ERROR_MESSAGE); + } + + public InvalidCredentialsException(String message) { + super(message); + } + + public InvalidCredentialsException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidCredentialsException(Throwable cause) { + super(cause); + } + + @Override + public ErrorStatus getErrorStatus() { + return UNAUTHORIZED_INVALID_CREDENTIALS; + } + +} diff --git a/src/main/java/gift/web/validation/exception/client/ResourceNotFoundException.java b/src/main/java/gift/web/validation/exception/client/ResourceNotFoundException.java new file mode 100644 index 000000000..665459c33 --- /dev/null +++ b/src/main/java/gift/web/validation/exception/client/ResourceNotFoundException.java @@ -0,0 +1,28 @@ +package gift.web.validation.exception.client; + +import static gift.web.validation.exception.code.ErrorStatus.NOT_FOUND; + +import gift.web.validation.exception.CustomException; +import gift.web.validation.exception.code.ErrorStatus; + +public class ResourceNotFoundException extends CustomException { + + private static final String ERROR_MESSAGE = "해당 리소스를 찾을 수 없습니다. %s: %s"; + + public ResourceNotFoundException(String type, String identifier) { + super(ERROR_MESSAGE.formatted(type, identifier)); + } + + protected ResourceNotFoundException(String type, String identifier, Throwable cause) { + super(ERROR_MESSAGE.formatted(type, identifier), cause); + } + + protected ResourceNotFoundException(Throwable cause) { + super(cause); + } + + @Override + public ErrorStatus getErrorStatus() { + return NOT_FOUND; + } +} diff --git a/src/main/java/gift/web/validation/exception/code/Category.java b/src/main/java/gift/web/validation/exception/code/Category.java new file mode 100644 index 000000000..08b2b6d7d --- /dev/null +++ b/src/main/java/gift/web/validation/exception/code/Category.java @@ -0,0 +1,20 @@ +package gift.web.validation.exception.code; + +public enum Category { + + COMMON("common"), + AUTHENTICATION("authentication"), + AUTHORIZATION("authorization"), + POLICY("policy"), + ; + + private final String description; + + Category(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/gift/web/validation/exception/code/ErrorStatus.java b/src/main/java/gift/web/validation/exception/code/ErrorStatus.java new file mode 100644 index 000000000..d2e13612c --- /dev/null +++ b/src/main/java/gift/web/validation/exception/code/ErrorStatus.java @@ -0,0 +1,50 @@ +package gift.web.validation.exception.code; + +import org.springframework.http.HttpStatus; + +/** + * 에러 코드
+ * code - HTTP 상태 코드 + 두 자리 숫자(내부 규칙 정의) 으로 정의된다.
+ */ +public enum ErrorStatus { + + UNAUTHORIZED_INVALID_CREDENTIALS(-40100, Category.AUTHENTICATION, HttpStatus.UNAUTHORIZED), + UNAUTHORIZED_INVALID_TOKEN(-40101, Category.AUTHENTICATION, HttpStatus.UNAUTHORIZED), + + BAD_REQUEST(-40000, Category.COMMON, HttpStatus.BAD_REQUEST), + INVALID_PARAMETER(-40001, Category.COMMON, HttpStatus.BAD_REQUEST), + KAKAO_APPROVAL_NEEDED(-40002, Category.POLICY, HttpStatus.BAD_REQUEST), + SPECIAL_CHARACTER_NOT_ALLOWED(-40003, Category.POLICY, HttpStatus.BAD_REQUEST), + INVALID_PASSWORD_FORMAT(-40004, Category.POLICY, HttpStatus.BAD_REQUEST), + INCORRECT_PASSWORD(-40005, Category.AUTHENTICATION, HttpStatus.BAD_REQUEST), + INCORRECT_EMAIL(-40006, Category.AUTHENTICATION, HttpStatus.BAD_REQUEST), + ALREADY_EXISTS(-40007, Category.COMMON, HttpStatus.BAD_REQUEST), + + NOT_FOUND(-40400, Category.COMMON, HttpStatus.NOT_FOUND), + + INTERNAL_SERVER_ERROR(-50000, Category.COMMON, HttpStatus.INTERNAL_SERVER_ERROR), + ; + + private final int code; + private final Category category; + private final HttpStatus httpStatus; + + ErrorStatus(int code, Category category, HttpStatus httpStatus) { + this.code = code; + this.category = category; + this.httpStatus = httpStatus; + } + + public int getCode() { + return code; + } + + public Category getCategory() { + return category; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + +} diff --git a/src/main/java/gift/web/validation/exception/server/InternalServerException.java b/src/main/java/gift/web/validation/exception/server/InternalServerException.java new file mode 100644 index 000000000..e227daaa9 --- /dev/null +++ b/src/main/java/gift/web/validation/exception/server/InternalServerException.java @@ -0,0 +1,33 @@ +package gift.web.validation.exception.server; + +import static gift.web.validation.exception.code.ErrorStatus.INTERNAL_SERVER_ERROR; + +import gift.web.validation.exception.CustomException; +import gift.web.validation.exception.code.ErrorStatus; + +public class InternalServerException extends CustomException { + + private static final String ERROR_MESSAGE = "서버에서 오류가 발생했습니다."; + + public InternalServerException() { + super(ERROR_MESSAGE); + } + + public InternalServerException(String message) { + super(message); + } + + public InternalServerException(String message, Throwable cause) { + super(message, cause); + } + + public InternalServerException(Throwable cause) { + super(cause); + } + + @Override + public ErrorStatus getErrorStatus() { + return INTERNAL_SERVER_ERROR; + } + +} diff --git a/src/main/java/gift/web/validation/handler/ApiExceptionHandler.java b/src/main/java/gift/web/validation/handler/ApiExceptionHandler.java new file mode 100644 index 000000000..ef955d8f6 --- /dev/null +++ b/src/main/java/gift/web/validation/handler/ApiExceptionHandler.java @@ -0,0 +1,53 @@ +package gift.web.validation.handler; + +import static gift.web.validation.exception.code.ErrorStatus.INTERNAL_SERVER_ERROR; +import static gift.web.validation.exception.code.ErrorStatus.INVALID_PARAMETER; +import static gift.web.validation.exception.code.ErrorStatus.NOT_FOUND; + +import gift.web.dto.response.ErrorResponse; +import gift.web.validation.exception.CustomException; +import java.util.NoSuchElementException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException exception) { + BindingResult bindingResult = exception.getBindingResult(); + ErrorResponse errorResponse = ErrorResponse.from(bindingResult); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handleNoSuchElementException(NoSuchElementException exception) { + ErrorResponse errorResponse = ErrorResponse.of(INVALID_PARAMETER, "해당 자원을 찾을 수 없습니다."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException exception) { + ErrorResponse errorResponse = ErrorResponse.of(NOT_FOUND, exception.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException exception) { + ErrorResponse errorResponse = ErrorResponse.from(exception); + HttpStatus httpStatus = exception.getErrorStatus().getHttpStatus(); + return ResponseEntity.status(httpStatus).body(errorResponse); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception exception) { + ErrorResponse errorResponse = ErrorResponse.of(INTERNAL_SERVER_ERROR, exception.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} diff --git a/src/main/java/gift/web/validation/validator/HexColorValidator.java b/src/main/java/gift/web/validation/validator/HexColorValidator.java new file mode 100644 index 000000000..748662609 --- /dev/null +++ b/src/main/java/gift/web/validation/validator/HexColorValidator.java @@ -0,0 +1,23 @@ +package gift.web.validation.validator; + +import gift.web.validation.constraints.HexColor; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.regex.Pattern; + +public class HexColorValidator implements ConstraintValidator { + + private Pattern pattern; + private static final String HEX_PATTERN = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"; + + @Override + public void initialize(HexColor constraintAnnotation) { + pattern = Pattern.compile(HEX_PATTERN); + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return pattern.matcher(value).matches(); + } +} diff --git a/src/main/java/gift/web/validation/validator/KakaoApprovalValidator.java b/src/main/java/gift/web/validation/validator/KakaoApprovalValidator.java new file mode 100644 index 000000000..b05932391 --- /dev/null +++ b/src/main/java/gift/web/validation/validator/KakaoApprovalValidator.java @@ -0,0 +1,22 @@ +package gift.web.validation.validator; + +import gift.utils.StringUtils; +import gift.web.validation.constraints.RequiredKakaoApproval; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Set; + +public class KakaoApprovalValidator implements ConstraintValidator { + + private Set requiredKakaoApprovalNames; + + @Override + public void initialize(RequiredKakaoApproval constraintAnnotation) { + requiredKakaoApprovalNames = Set.of("카카오", "kakao"); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return !StringUtils.containsAnySubstring(value.toLowerCase(), requiredKakaoApprovalNames); + } +} diff --git a/src/main/java/gift/web/validation/validator/PasswordValidator.java b/src/main/java/gift/web/validation/validator/PasswordValidator.java new file mode 100644 index 000000000..462667474 --- /dev/null +++ b/src/main/java/gift/web/validation/validator/PasswordValidator.java @@ -0,0 +1,38 @@ +package gift.web.validation.validator; + +import gift.web.validation.constraints.Password; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class PasswordValidator implements ConstraintValidator { + + private final int MIN_LENGTH = 8; + private final int MAX_LENGTH = 15; + + @Override + public void initialize(Password constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + /** + * 비밀번호가 제약조건에 맞는지 확인한다.
+ * - 길이는 8자 이상 15자 이하
+ * - 영문자와 숫자를 최소 1자 이상 포함하여야 한다. + * - 영문자와 숫자 이외의 문자는 허용하지 않는다.
+ * @param password 검증 대상 + * @param context 컨텍스트 + * + * @return 제약조건을 만족하면 true, 그렇지 않으면 false + */ + @Override + public boolean isValid(String password, ConstraintValidatorContext context) { + if (isInvalidLength(password)) { + return false; + } + return password.matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]+$"); + } + + private boolean isInvalidLength(String password) { + return password == null || password.length() < MIN_LENGTH || password.length() > MAX_LENGTH; + } +} diff --git a/src/main/java/gift/web/validation/validator/SpecialCharacterValidator.java b/src/main/java/gift/web/validation/validator/SpecialCharacterValidator.java new file mode 100644 index 000000000..287e758a9 --- /dev/null +++ b/src/main/java/gift/web/validation/validator/SpecialCharacterValidator.java @@ -0,0 +1,30 @@ +package gift.web.validation.validator; + +import gift.utils.StringUtils; +import gift.web.validation.constraints.SpecialCharacter; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +public class SpecialCharacterValidator implements ConstraintValidator { + + private Set allowedSpecialChars; + private final String DELIMITER = ","; + + @Override + public void initialize(SpecialCharacter constraintAnnotation) { + String allowedChars = constraintAnnotation.allowed(); + allowedSpecialChars = Arrays.stream(allowedChars.split(DELIMITER)) + .map(String::trim) + .map(s -> s.charAt(0)) + .collect(Collectors.toSet()); + + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return StringUtils.containsOnlyAllowedSpecialChars(value, allowedSpecialChars); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 000000000..dce64893e --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,18 @@ +spring: + config: + import: application-secret.yml + + sql: + init: + mode: always + data-locations: classpath:sql/data.sql + + jpa: + defer-datasource-initialization: true #Hibernate 초기화 후 data.sql 실행 + +jwt: + expiration: 86400000 #24시간 + +logging: + level: + gift.authentication: debug \ No newline at end of file diff --git a/src/main/resources/application-secret.yml b/src/main/resources/application-secret.yml new file mode 100644 index 000000000..e776ae408 --- /dev/null +++ b/src/main/resources/application-secret.yml @@ -0,0 +1,8 @@ +spring: + datasource: + url: jdbc:h2:tcp://localhost/~/gift + username: sa + password: + +jwt: + secretKey: db8dc50b1bf35d72218b9961ed669ef3dabbd8ddd617c235baeb8a020c66179661e6e148b8c84163af02394a1c59a0af0c4566dc17325a03c77f2027f16d9b54 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 000000000..12a1e66ba --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,19 @@ +spring: + application: + name: spring-gift + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + +logging: + level: + org: + hibernate: + SQL: debug + orm: + jdbc: + bind: trace \ No newline at end of file diff --git a/src/main/resources/sql/data.sql b/src/main/resources/sql/data.sql new file mode 100644 index 000000000..f596a8793 --- /dev/null +++ b/src/main/resources/sql/data.sql @@ -0,0 +1,154 @@ +INSERT INTO product(name, price, image_url, category_id, created_at, created_by, updated_at, updated_by) +VALUES + ('Product 1', 1000, 'https://via.placeholder.com/150', 1, NOW(), 1, NOW(), 1), + ('Product 2', 2000, 'https://via.placeholder.com/250', 1, NOW(), 1, NOW(), 1), + ('Product 3', 3000, 'https://via.placeholder.com/350', 1, NOW(), 1, NOW(), 1), + ('Product 4', 4000, 'https://via.placeholder.com/450', 1, NOW(), 1, NOW(), 1), + ('Product 5', 5000, 'https://via.placeholder.com/550', 1, NOW(), 1, NOW(), 1), + ('Product 6', 6000, 'https://via.placeholder.com/650', 1, NOW(), 1, NOW(), 1), + ('Product 7', 7000, 'https://via.placeholder.com/750', 1, NOW(), 1, NOW(), 1), + ('Product 8', 8000, 'https://via.placeholder.com/850', 1, NOW(), 1, NOW(), 1), + ('Product 9', 9000, 'https://via.placeholder.com/950', 1, NOW(), 1, NOW(), 1), + ('Product 10', 10000, 'https://via.placeholder.com/1050', 2, NOW(), 1, NOW(), 1), + ('Product 11', 11000, 'https://via.placeholder.com/1150', 2, NOW(), 1, NOW(), 1), + ('Product 12', 12000, 'https://via.placeholder.com/1250', 2, NOW(), 1, NOW(), 1), + ('Product 13', 13000, 'https://via.placeholder.com/1350', 2, NOW(), 1, NOW(), 1), + ('Product 14', 14000, 'https://via.placeholder.com/1450', 2, NOW(), 1, NOW(), 1), + ('Product 15', 15000, 'https://via.placeholder.com/1550', 2, NOW(), 1, NOW(), 1), + ('Product 16', 16000, 'https://via.placeholder.com/1650', 2, NOW(), 1, NOW(), 1), + ('Product 17', 17000, 'https://via.placeholder.com/1750', 2, NOW(), 1, NOW(), 1), + ('Product 18', 18000, 'https://via.placeholder.com/1850', 2, NOW(), 1, NOW(), 1), + ('Product 19', 19000, 'https://via.placeholder.com/1950', 2, NOW(), 1, NOW(), 1), + ('Product 20', 20000, 'https://via.placeholder.com/2050', 2, NOW(), 1, NOW(), 1), + ('Product 21', 21000, 'https://via.placeholder.com/2150', 2, NOW(), 1, NOW(), 1), + ('Product 22', 22000, 'https://via.placeholder.com/2250', 2, NOW(), 1, NOW(), 1), + ('Product 23', 23000, 'https://via.placeholder.com/2350', 2, NOW(), 1, NOW(), 1), + ('Product 24', 24000, 'https://via.placeholder.com/2450', 2, NOW(), 1, NOW(), 1), + ('Product 25', 25000, 'https://via.placeholder.com/2550', 2, NOW(), 1, NOW(), 1), + ('Product 26', 26000, 'https://via.placeholder.com/2650', 2, NOW(), 1, NOW(), 1), + ('Product 27', 27000, 'https://via.placeholder.com/2750', 2, NOW(), 1, NOW(), 1), + ('Product 28', 28000, 'https://via.placeholder.com/2850', 2, NOW(), 1, NOW(), 1), + ('Product 29', 29000, 'https://via.placeholder.com/2950', 2, NOW(), 1, NOW(), 1), + ('Product 30', 30000, 'https://via.placeholder.com/3050', 2, NOW(), 1, NOW(), 1), + ('Product 31', 31000, 'https://via.placeholder.com/3150', 2, NOW(), 1, NOW(), 1), + ('Product 32', 32000, 'https://via.placeholder.com/3250', 2, NOW(), 1, NOW(), 1), + ('Product 33', 33000, 'https://via.placeholder.com/3350', 2, NOW(), 1, NOW(), 1), + ('Product 34', 34000, 'https://via.placeholder.com/3450', 2, NOW(), 1, NOW(), 1), + ('Product 35', 35000, 'https://via.placeholder.com/3550', 3, NOW(), 1, NOW(), 1), + ('Product 36', 36000, 'https://via.placeholder.com/3650', 3, NOW(), 1, NOW(), 1), + ('Product 37', 37000, 'https://via.placeholder.com/3750', 3, NOW(), 1, NOW(), 1), + ('Product 38', 38000, 'https://via.placeholder.com/3850', 3, NOW(), 1, NOW(), 1), + ('Product 39', 39000, 'https://via.placeholder.com/3950', 3, NOW(), 1, NOW(), 1), + ('Product 40', 40000, 'https://via.placeholder.com/4050', 3, NOW(), 1, NOW(), 1), + ('Product 41', 41000, 'https://via.placeholder.com/4150', 3, NOW(), 1, NOW(), 1), + ('Product 42', 42000, 'https://via.placeholder.com/4250', 3, NOW(), 1, NOW(), 1), + ('Product 43', 43000, 'https://via.placeholder.com/4350', 3, NOW(), 1, NOW(), 1), + ('Product 44', 44000, 'https://via.placeholder.com/4450', 3, NOW(), 1, NOW(), 1), + ('Product 45', 45000, 'https://via.placeholder.com/4550', 3, NOW(), 1, NOW(), 1), + ('Product 46', 46000, 'https://via.placeholder.com/4650', 3, NOW(), 1, NOW(), 1), + ('Product 47', 47000, 'https://via.placeholder.com/4750', 3, NOW(), 1, NOW(), 1), + ('Product 48', 48000, 'https://via.placeholder.com/4850', 3, NOW(), 1, NOW(), 1), + ('Product 49', 49000, 'https://via.placeholder.com/4950', 3, NOW(), 1, NOW(), 1), + ('Product 50', 50000, 'https://via.placeholder.com/5050', 3, NOW(), 1, NOW(), 1); + +INSERT INTO member(name, email, password, created_at, updated_at) +VALUES + ('Member 1', 'member01@gmail.com', 'member010101', NOW(), NOW()), + ('Member 2', 'member02@gmail.com', 'member020202', NOW(), NOW()), + ('Member 3', 'member03@gmail.com', 'member030303', NOW(), NOW()); + +INSERT INTO wish_product(member_id, product_id, quantity, created_at, created_by, updated_at, updated_by) +VALUES + (1, 1, 1, NOW(), 1, NOW(), 1), + (1, 2, 1, NOW(), 1, NOW(), 1), + (1, 3, 1, NOW(), 1, NOW(), 1), + (1, 4, 1, NOW(), 1, NOW(), 1), + (1, 5, 1, NOW(), 1, NOW(), 1), + (1, 6, 1, NOW(), 1, NOW(), 1), + (1, 7, 1, NOW(), 1, NOW(), 1), + (1, 8, 1, NOW(), 1, NOW(), 1), + (1, 9, 1, NOW(), 1, NOW(), 1), + (1, 10, 1, NOW(), 1, NOW(), 1), + (1, 11, 1, NOW(), 1, NOW(), 1), + (1, 12, 1, NOW(), 1, NOW(), 1), + (1, 13, 1, NOW(), 1, NOW(), 1), + (1, 14, 1, NOW(), 1, NOW(), 1), + (1, 15, 1, NOW(), 1, NOW(), 1), + (1, 16, 1, NOW(), 1, NOW(), 1), + (1, 17, 1, NOW(), 1, NOW(), 1), + (1, 18, 1, NOW(), 1, NOW(), 1), + (1, 19, 1, NOW(), 1, NOW(), 1), + (1, 20, 1, NOW(), 1, NOW(), 1), + (1, 21, 1, NOW(), 1, NOW(), 1), + (1, 22, 1, NOW(), 1, NOW(), 1), + (1, 23, 1, NOW(), 1, NOW(), 1), + (1, 24, 1, NOW(), 1, NOW(), 1), + (1, 25, 1, NOW(), 1, NOW(), 1), + (1, 26, 1, NOW(), 1, NOW(), 1), + (1, 27, 1, NOW(), 1, NOW(), 1), + (1, 28, 1, NOW(), 1, NOW(), 1), + (1, 29, 1, NOW(), 1, NOW(), 1), + (1, 30, 1, NOW(), 1, NOW(), 1), + (1, 31, 1, NOW(), 1, NOW(), 1), + (1, 32, 1, NOW(), 1, NOW(), 1), + (1, 33, 1, NOW(), 1, NOW(), 1), + (1, 34, 1, NOW(), 1, NOW(), 1), + (1, 35, 1, NOW(), 1, NOW(), 1), + (1, 36, 1, NOW(), 1, NOW(), 1), + (1, 37, 1, NOW(), 1, NOW(), 1), + (1, 38, 1, NOW(), 1, NOW(), 1), + (1, 39, 1, NOW(), 1, NOW(), 1), + (1, 40, 1, NOW(), 1, NOW(), 1), + (1, 41, 1, NOW(), 1, NOW(), 1), + (1, 42, 1, NOW(), 1, NOW(), 1), + (1, 43, 1, NOW(), 1, NOW(), 1), + (1, 44, 1, NOW(), 1, NOW(), 1), + (1, 45, 1, NOW(), 1, NOW(), 1), + (1, 46, 1, NOW(), 1, NOW(), 1), + (1, 47, 1, NOW(), 1, NOW(), 1), + (1, 48, 1, NOW(), 1, NOW(), 1), + (1, 49, 1, NOW(), 1, NOW(), 1), + (1, 50, 1, NOW(), 1, NOW(), 1); + +INSERT INTO category(name, description, image_url, color, created_at, created_by, updated_at, updated_by) +VALUES + ('Category 1', 'Category 1 Description', 'https://via.placeholder.com/150', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 2', 'Category 2 Description', 'https://via.placeholder.com/250', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 3', 'Category 3 Description', 'https://via.placeholder.com/350', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 4', 'Category 4 Description', 'https://via.placeholder.com/450', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 5', 'Category 5 Description', 'https://via.placeholder.com/550', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 6', 'Category 6 Description', 'https://via.placeholder.com/650', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 7', 'Category 7 Description', 'https://via.placeholder.com/750', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 8', 'Category 8 Description', 'https://via.placeholder.com/850', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 9', 'Category 9 Description', 'https://via.placeholder.com/950', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 10', 'Category 10 Description', 'https://via.placeholder.com/1050', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 11', 'Category 11 Description', 'https://via.placeholder.com/1150', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 12', 'Category 12 Description', 'https://via.placeholder.com/1250', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 13', 'Category 13 Description', 'https://via.placeholder.com/1350', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 14', 'Category 14 Description', 'https://via.placeholder.com/1450', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 15', 'Category 15 Description', 'https://via.placeholder.com/1550', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 16', 'Category 16 Description', 'https://via.placeholder.com/1650', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 17', 'Category 17 Description', 'https://via.placeholder.com/1750', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 18', 'Category 18 Description', 'https://via.placeholder.com/1850', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 19', 'Category 19 Description', 'https://via.placeholder.com/1950', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 20', 'Category 20 Description', 'https://via.placeholder.com/2050', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 21', 'Category 21 Description', 'https://via.placeholder.com/2150', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 22', 'Category 22 Description', 'https://via.placeholder.com/2250', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 23', 'Category 23 Description', 'https://via.placeholder.com/2350', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 24', 'Category 24 Description', 'https://via.placeholder.com/2450', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 25', 'Category 25 Description', 'https://via.placeholder.com/2550', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 26', 'Category 26 Description', 'https://via.placeholder.com/2650', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 27', 'Category 27 Description', 'https://via.placeholder.com/2750', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 28', 'Category 28 Description', 'https://via.placeholder.com/2850', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 29', 'Category 29 Description', 'https://via.placeholder.com/2950', '#FF5733', NOW(), 1, NOW(), 1), + ('Category 30', 'Category 30 Description', 'https://via.placeholder.com/3050', '#FF5733', NOW(), 1, NOW(), 1); + +INSERT INTO product_option(name, stock, product_id, created_at, created_by, updated_at, updated_by) +VALUES + ('Option 1', 100, 1, NOW(), 1, NOW(), 1), + ('Option 2', 200, 1, NOW(), 1, NOW(), 1), + ('Option 3', 300, 1, NOW(), 1, NOW(), 1), + ('Option 4', 400, 2, NOW(), 2, NOW(), 2), + ('Option 5', 500, 2, NOW(), 2, NOW(), 2), + ('Option 6', 600, 2, NOW(), 2, NOW(), 2), + ('Option 7', 700, 2, NOW(), 2, NOW(), 2); \ No newline at end of file diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql new file mode 100644 index 000000000..9eac7df36 --- /dev/null +++ b/src/main/resources/sql/schema.sql @@ -0,0 +1,25 @@ +DROP TABLE IF EXISTS Product; +CREATE TABLE product ( + product_id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price INT NOT NULL, + image_url VARCHAR(255) NOT NULL +); + +DROP TABLE IF EXISTS member; +CREATE TABLE member ( + member_id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL +); + +DROP TABLE IF EXISTS wish_product; +CREATE TABLE wish_product ( + wish_product_id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + quantity INT NOT NULL, + FOREIGN KEY (member_id) REFERENCES member(member_id), + FOREIGN KEY (product_id) REFERENCES product(product_id) +); \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 000000000..ef654ad6c --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,16 @@ + + + + + + Main Page + + + +
+

메인 화면

+
+ 관리자 페이지로 이동 +
+ + diff --git a/src/main/resources/static/js/script.js b/src/main/resources/static/js/script.js new file mode 100644 index 000000000..8738a2c8e --- /dev/null +++ b/src/main/resources/static/js/script.js @@ -0,0 +1,86 @@ +function submitEditForm(productId) { + const form = document.getElementById('editProductForm'); + const formData = new FormData(form); + + const data = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + + console.log('Form data:', data); + + fetch('/api/products/' + productId, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }) + .then(response => { + if (!response.ok) { + throw new Error('알 수 없는 에러가 발생했습니다! ' + response.statusText); + } + return response.json(); + }) + .then(data => { + alert('상품 수정이 완료되었습니다!'); + console.log(data); + window.location.href = '/view/products'; + }) + .catch(error => { + console.error('알 수 없는 에러가 발생했습니다! ', error); + }); +} + +function submitAddForm() { + const form = document.getElementById('addProductForm'); + const formData = new FormData(form); + + const data = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + + console.log('Form data:', data); + + fetch('/api/products', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }) + .then(response => { + if (!response.ok) { + throw new Error('알 수 없는 에러가 발생했습니다! ' + response.statusText); + } + return response.json(); + }) + .then(data => { + alert('상품 추가가 완료되었습니다!'); + console.log(data); + window.location.href = '/view/products'; + }) + .catch(error => { + console.error('알 수 없는 에러가 발생했습니다! ', error); + }); +} + +function deleteProductById(productId) { + fetch('/api/products/' + productId, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(response => { + if (!response.ok) { + throw new Error('알 수 없는 에러가 발생했습니다! ' + response.statusText); + } + alert('상품 삭제가 완료되었습니다!'); + window.location.href = '/view/products'; + }) + .catch(error => { + console.error('알 수 없는 에러가 발생했습니다! ', error); + }); +} diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html new file mode 100644 index 000000000..4e3cf3f53 --- /dev/null +++ b/src/main/resources/templates/admin.html @@ -0,0 +1,44 @@ + + + + + 관리자 페이지 + + + +
+

관리자 페이지 - 상품 관리

+ + + + + + + + + + + + + + + + + + + + +
상품ID상품명상품 가격이미지 주소Actions
1234name0https://www.google.com +
+
+ +
+ +
+
+
+ + + diff --git a/src/main/resources/templates/form/add-product-form.html b/src/main/resources/templates/form/add-product-form.html new file mode 100644 index 000000000..8ecd0ddcd --- /dev/null +++ b/src/main/resources/templates/form/add-product-form.html @@ -0,0 +1,29 @@ + + + + + 상품 등록 + + + +
+

상품 등록

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/form/edit-product-form.html b/src/main/resources/templates/form/edit-product-form.html new file mode 100644 index 000000000..846ac4b99 --- /dev/null +++ b/src/main/resources/templates/form/edit-product-form.html @@ -0,0 +1,29 @@ + + + + + 상품 수정 + + + +
+

상품 수정

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + \ No newline at end of file diff --git a/src/test/java/gift/domain/ProductTest.java b/src/test/java/gift/domain/ProductTest.java new file mode 100644 index 000000000..c93b255f8 --- /dev/null +++ b/src/test/java/gift/domain/ProductTest.java @@ -0,0 +1,45 @@ +package gift.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductTest { + + @Nested + @DisplayName("Product 생성자는 ") + class Describe_createProduct { + @Test + @DisplayName("상품 옵션이 없으면 예외를 발생시킨다.") + void create_product_with_no_option() { + assertThatThrownBy(() -> new Product.Builder() + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품 옵션은 최소 1개 이상이어야 합니다."); + } + + @Test + @DisplayName("상품 옵션이 있으면 Product를 생성한다.") + void create_product_with_option() { + //given + List productOptions = List.of(new ProductOption.Builder().build()); + + //when + Product product = new Product.Builder() + .productOptions(productOptions) + .build(); + + //then + assertAll( + () -> assertNotNull(product), + () -> assertEquals(productOptions, product.getProductOptions()) + ); + } + } +} \ No newline at end of file diff --git a/src/test/java/gift/service/CategoryServiceTest.java b/src/test/java/gift/service/CategoryServiceTest.java new file mode 100644 index 000000000..e10ab7877 --- /dev/null +++ b/src/test/java/gift/service/CategoryServiceTest.java @@ -0,0 +1,155 @@ +package gift.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import gift.converter.StringToUrlConverter; +import gift.domain.Category; +import gift.domain.Category.Builder; +import gift.domain.vo.Color; +import gift.repository.CategoryRepository; +import gift.web.dto.request.category.CreateCategoryRequest; +import gift.web.dto.request.category.UpdateCategoryRequest; +import gift.web.dto.response.category.CreateCategoryResponse; +import gift.web.dto.response.category.ReadAllCategoriesResponse; +import gift.web.dto.response.category.ReadCategoryResponse; +import gift.web.dto.response.category.UpdateCategoryResponse; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@ExtendWith(MockitoExtension.class) +class CategoryServiceTest { + + @Mock + private CategoryRepository categoryRepository; + + @InjectMocks + private CategoryService categoryService; + + @Test + @DisplayName("생성 요청이 정상적일 때, 카테고리를 성공적으로 생성합니다.") + void createCategory() { + //given + CreateCategoryRequest request = new CreateCategoryRequest("카테고리01", "카테고리01 설명", "https://www.google.com", "#FFFFFF"); + Category category = request.toEntity(); + given(categoryRepository.save(any(Category.class))).willReturn(category); + + //when + CreateCategoryResponse response = categoryService.createCategory(request); + + //then + assertAll( + () -> assertThat(response.getName()).isEqualTo(category.getName()), + () -> assertThat(response.getDescription()).isEqualTo(category.getDescription()), + () -> assertThat(response.getImageUrl()).isEqualTo(category.getImageUrl().toString()), + () -> assertThat(response.getColor()).isEqualTo(category.getColor().toString()) + ); + } + + @Test + @DisplayName("모든 카테고리 조회 요청이 정상적일 때, 모든 카테고리를 성공적으로 조회합니다.") + void readAllCategories() { + //given + List categories = List.of( + new Category.Builder().name("카테고리01").description("카테고리01 설명").imageUrl(StringToUrlConverter.convert("https://www.google.com")).color(Color.from("#FFFFFF")).build(), + new Category.Builder().name("카테고리02").description("카테고리02 설명").imageUrl(StringToUrlConverter.convert("https://www.google.com")).color(Color.from("#FFFFFF")).build(), + new Category.Builder().name("카테고리03").description("카테고리03 설명").imageUrl(StringToUrlConverter.convert("https://www.google.com")).color(Color.from("#FFFFFF")).build() + ); + Page page = new PageImpl<>(categories); + given(categoryRepository.findAll(any(PageRequest.class))).willReturn(page); + + //when + ReadAllCategoriesResponse response = categoryService.readAllCategories(PageRequest.of(0, 10)); + + //then + List actual = response.getCategories(); + Assertions.assertAll( + () -> assertThat(actual).hasSize(3), + () -> assertThat(actual.get(0).getName()).isEqualTo(categories.get(0).getName()), + () -> assertThat(actual.get(0).getDescription()).isEqualTo(categories.get(0).getDescription()), + () -> assertThat(actual.get(0).getImageUrl()).isEqualTo(categories.get(0).getImageUrl().toString()), + () -> assertThat(actual.get(0).getColor()).isEqualTo(categories.get(0).getColor().toString()), + + () -> assertThat(actual.get(1).getName()).isEqualTo(categories.get(1).getName()), + () -> assertThat(actual.get(1).getDescription()).isEqualTo(categories.get(1).getDescription()), + () -> assertThat(actual.get(1).getImageUrl()).isEqualTo(categories.get(1).getImageUrl().toString()), + () -> assertThat(actual.get(1).getColor()).isEqualTo(categories.get(1).getColor().toString()), + + () -> assertThat(actual.get(2).getName()).isEqualTo(categories.get(2).getName()), + () -> assertThat(actual.get(2).getDescription()).isEqualTo(categories.get(2).getDescription()), + () -> assertThat(actual.get(2).getImageUrl()).isEqualTo(categories.get(2).getImageUrl().toString()), + () -> assertThat(actual.get(2).getColor()).isEqualTo(categories.get(2).getColor().toString()) + ); + } + + @Test + @DisplayName("단일 카테고리 조회 요청이 정상적일 때, 카테고리를 성공적으로 조회합니다.") + void readCategory() { + //given + Category category = new Builder() + .id(1L) + .name("카테고리01") + .description("카테고리01 설명") + .imageUrl(StringToUrlConverter.convert("https://www.google.com")) + .color(Color.from("#FFFFFF")) + .build(); + given(categoryRepository.findById(any(Long.class))).willReturn(Optional.of(category)); + + //when + ReadCategoryResponse response = categoryService.readCategory(1L); + + //then + assertAll( + () -> assertThat(response.getId()).isEqualTo(category.getId()), + () -> assertThat(response.getName()).isEqualTo(category.getName()), + () -> assertThat(response.getDescription()).isEqualTo(category.getDescription()), + () -> assertThat(response.getImageUrl()).isEqualTo(category.getImageUrl().toString()), + () -> assertThat(response.getColor()).isEqualTo(category.getColor().toString()) + ); + } + + @Test + @DisplayName("카테고리 수정 요청이 정상적일 때, 카테고리를 성공적으로 수정합니다.") + void updateCategory() { + //given + UpdateCategoryRequest request = new UpdateCategoryRequest("카테고리01", + "카테고리01 설명", "https://www.google.com", "#FFFFFF"); + given(categoryRepository.findById(any(Long.class))).willReturn(Optional.of(new Category.Builder().build())); + + //when + UpdateCategoryResponse response = categoryService.updateCategory(1L, request); + + //then + assertAll( + () -> assertThat(response.getName()).isEqualTo(request.getName()), + () -> assertThat(response.getDescription()).isEqualTo(request.getDescription()), + () -> assertThat(response.getImageUrl()).isEqualTo(request.getImageUrl()), + () -> assertThat(response.getColor()).isEqualTo(request.getColor()) + ); + } + + @Test + @DisplayName("카테고리 삭제 요청이 정상적일 때, 카테고리를 성공적으로 삭제합니다.") + void deleteCategory() { + //given + Long categoryId = 1L; + + //when + categoryService.deleteCategory(categoryId); + + //then + Assertions.assertDoesNotThrow(() -> categoryRepository.findById(1L)); + } +} \ No newline at end of file diff --git a/src/test/java/gift/service/MemberServiceTest.java b/src/test/java/gift/service/MemberServiceTest.java new file mode 100644 index 000000000..09c2e0db6 --- /dev/null +++ b/src/test/java/gift/service/MemberServiceTest.java @@ -0,0 +1,115 @@ +package gift.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import gift.authentication.token.JwtProvider; +import gift.authentication.token.Token; +import gift.domain.Member; +import gift.domain.vo.Email; +import gift.domain.vo.Password; +import gift.repository.MemberRepository; +import gift.repository.WishProductRepository; +import gift.web.dto.request.LoginRequest; +import gift.web.dto.request.member.CreateMemberRequest; +import gift.web.dto.response.LoginResponse; +import gift.web.dto.response.member.CreateMemberResponse; +import gift.web.dto.response.member.ReadMemberResponse; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @InjectMocks + private MemberService memberService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private WishProductRepository wishProductRepository; + + @Mock + private JwtProvider jwtProvider; + + @Test + @DisplayName("회원 생성 요청이 정상적일 때, 회원을 성공적으로 생성합니다.") + void createMember() { + //given + CreateMemberRequest request = new CreateMemberRequest("member01@naver.com", "password01", "이름"); + given(memberRepository.save(any())).willReturn( + new Member.Builder().id(1L).name(request.getName()).email(Email.from(request.getEmail())).build()); + + //when + CreateMemberResponse response = memberService.createMember(request); + + //then + assertAll( + () -> assertThat(response.getId()).isNotNull(), + () -> assertThat(response.getEmail()).isEqualTo(request.getEmail()), + () -> assertThat(response.getName()).isEqualTo(request.getName()) + ); + } + + @Test + @DisplayName("회원 조회 요청이 정상적일 때, 회원을 성공적으로 조회합니다.") + void readMember() { + //given + Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).password( + Password.from("password01")).build(); + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + + //when + ReadMemberResponse response = memberService.readMember(1L); + + //then + assertAll( + () -> assertThat(response.getId()).isEqualTo(member.getId()), + () -> assertThat(response.getName()).isEqualTo(member.getName()), + () -> assertThat(response.getEmail()).isEqualTo(member.getEmail().getValue()) + ); + } + + @Test + @DisplayName("회원 삭제 요청이 정상적일 때, 회원을 성공적으로 삭제합니다.") + void deleteMember() { + //given + Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).password( + Password.from("password01")).build(); + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + + //when + //then + assertDoesNotThrow(() -> memberService.deleteMember(1L)); + } + + @Test + @DisplayName("로그인 요청이 정상적일 때, 로그인을 성공적으로 수행합니다.") + void login() { + //given + String email = "member01@naver.com"; + String password = "password01"; + Member member = new Member.Builder().id(1L).email(Email.from(email)).password( + Password.from(password)).build(); + + LoginRequest request = new LoginRequest(email, password); + + given(memberRepository.findByEmail(Email.from(email))).willReturn(Optional.of(member)); + given(jwtProvider.generateToken(member)).willReturn(Token.from("token")); + + //when + LoginResponse response = memberService.login(request); + + //then + assertThat(response.getToken()).isNotNull(); + } +} \ No newline at end of file diff --git a/src/test/java/gift/service/ProductOptionServiceTest.java b/src/test/java/gift/service/ProductOptionServiceTest.java new file mode 100644 index 000000000..69b413f0c --- /dev/null +++ b/src/test/java/gift/service/ProductOptionServiceTest.java @@ -0,0 +1,179 @@ +package gift.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import gift.domain.ProductOption; +import gift.domain.ProductOption.Builder; +import gift.repository.ProductOptionRepository; +import gift.web.dto.request.productoption.CreateProductOptionRequest; +import gift.web.dto.request.productoption.SubtractProductOptionQuantityRequest; +import gift.web.dto.request.productoption.UpdateProductOptionRequest; +import gift.web.dto.response.productoption.CreateProductOptionResponse; +import gift.web.dto.response.productoption.ReadAllProductOptionsResponse; +import gift.web.dto.response.productoption.SubtractProductOptionQuantityResponse; +import gift.web.dto.response.productoption.UpdateProductOptionResponse; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ProductOptionServiceTest { + + @InjectMocks + private ProductOptionService productOptionService; + + @Mock + private ProductOptionRepository productOptionRepository; + + @Test + @DisplayName("상품 옵션 생성 요청이 정상적일 때, 상품 옵션을 성공적으로 생성합니다.") + void createOption() { + //given + Long productId = 1L; + CreateProductOptionRequest request = new CreateProductOptionRequest("optionName", 1000); + given(productOptionRepository.save(any())).willReturn(request.toEntity(productId)); + + //when + CreateProductOptionResponse response = productOptionService.createOption(productId, request); + + //then + assertAll( + () -> assertThat(response.getName()).isEqualTo(request.getName()), + () -> assertThat(response.getStock()).isEqualTo(request.getStock()) + ); + } + + @Test + @DisplayName("상품 옵션 생성 요청이 중복된 이름일 때, 예외를 발생시킵니다.") + void createInitialOptions() { + //given + List request = List.of( + new CreateProductOptionRequest("optionName", 1000), + new CreateProductOptionRequest("optionName", 1000)); + + //when + //then + assertThatThrownBy(() -> productOptionService.createInitialOptions(1L, request)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("상품 옵션 조회 요청이 정상적일 때, 상품 옵션을 성공적으로 조회합니다.") + void readAllOptions() { + //given + ProductOption option01 = new Builder().productId(1L).name("optionName").stock(1000).build(); + ProductOption option02 = new Builder().productId(1L).name("optionName").stock(1000).build(); + given(productOptionRepository.findAllByProductId(1L)).willReturn(List.of(option01, option02)); + + //when + ReadAllProductOptionsResponse response = productOptionService.readAllOptions(1L); + + //then + assertAll( + () -> assertThat(response.getOptions().get(0).getName()).isEqualTo(option01.getName()), + () -> assertThat(response.getOptions().get(0).getStock()).isEqualTo(option01.getStock()), + () -> assertThat(response.getOptions().get(1).getName()).isEqualTo(option02.getName()), + () -> assertThat(response.getOptions().get(1).getStock()).isEqualTo(option02.getStock()) + ); + } + + @Test + @DisplayName("상품 옵션 수정 요청이 정상적일 때, 상품 옵션을 성공적으로 수정합니다.") + void updateOption() { + //given + Long productId = 1L; + Long optionId = 1L; + UpdateProductOptionRequest request = new UpdateProductOptionRequest("optionName", 1000); + ProductOption option = new Builder().productId(productId).name("optionName").stock(1000).build(); + given(productOptionRepository.findById(optionId)).willReturn(Optional.of(option)); + + //when + UpdateProductOptionResponse response = productOptionService.updateOption(productId, optionId, request); + + //then + assertAll( + () -> assertThat(response.getName()).isEqualTo(request.getName()), + () -> assertThat(response.getStock()).isEqualTo(request.getStock()) + ); + } + + @Nested + @DisplayName("subtractOptionStock 메서드는") + class SubtractOptionStock { + + @Test + @DisplayName("요청 수량이 재고보다 적을 때, 재고를 감소시킨다.") + void valid_request() { + //given + int requestedQuantity = 3; + SubtractProductOptionQuantityRequest request = new SubtractProductOptionQuantityRequest( + requestedQuantity); + + int stock = 10; + ProductOption productOption = new Builder().stock(stock).build(); + given(productOptionRepository.findById(any())).willReturn(Optional.of(productOption)); + + //when + SubtractProductOptionQuantityResponse response = productOptionService.subtractOptionStock( + 1L, request); + + //then + int expectedStock = stock - requestedQuantity; + assertThat(response.getStock()).isEqualTo(expectedStock); + } + + @Test + @DisplayName("요청 수량이 재고보다 많을 때, 예외를 발생시킨다.") + void invalid_request() { + //given + int requestedQuantity = 11; + SubtractProductOptionQuantityRequest request = new SubtractProductOptionQuantityRequest( + requestedQuantity); + + int stock = 10; + ProductOption productOption = new Builder().stock(stock).build(); + given(productOptionRepository.findById(any())).willReturn(Optional.of(productOption)); + + //when + //then + assertThatThrownBy(() -> productOptionService.subtractOptionStock(1L, request)) + .isInstanceOf(IllegalStateException.class); + } + } + + @Test + @DisplayName("상품 옵션 삭제 요청이 정상적일 때, 상품 옵션을 성공적으로 삭제합니다.") + void deleteOption() { + //given + Long optionId = 1L; + + //when + given(productOptionRepository.findById(optionId)).willReturn(Optional.of(new ProductOption.Builder().build())); + + //then + assertDoesNotThrow(() -> productOptionService.deleteOption(optionId)); + } + + @Test + @DisplayName("상품 옵션 전체 삭제 요청이 정상적일 때, 상품 옵션을 성공적으로 전체 삭제합니다.") + void deleteAllOptionsByProductId() { + //given + Long productId = 1L; + + //when + //then + assertDoesNotThrow(() -> productOptionService.deleteAllOptionsByProductId(productId)); + } +} \ No newline at end of file diff --git a/src/test/java/gift/service/ProductServiceTest.java b/src/test/java/gift/service/ProductServiceTest.java new file mode 100644 index 000000000..ad43d05ca --- /dev/null +++ b/src/test/java/gift/service/ProductServiceTest.java @@ -0,0 +1,264 @@ +package gift.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import gift.converter.StringToUrlConverter; +import gift.domain.Category; +import gift.domain.Category.Builder; +import gift.domain.Product; +import gift.domain.ProductOption; +import gift.domain.vo.Color; +import gift.repository.CategoryRepository; +import gift.repository.ProductOptionRepository; +import gift.repository.ProductRepository; +import gift.repository.WishProductRepository; +import gift.web.dto.request.product.CreateProductRequest; +import gift.web.dto.request.product.UpdateProductRequest; +import gift.web.dto.request.productoption.CreateProductOptionRequest; +import gift.web.dto.response.product.CreateProductResponse; +import gift.web.dto.response.product.ReadAllProductsResponse; +import gift.web.dto.response.product.ReadProductResponse; +import gift.web.dto.response.product.UpdateProductResponse; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + private ProductService productService; + + private ProductOptionService productOptionService; + + @Mock + private ProductRepository productRepository; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private WishProductRepository wishProductRepository; + + @Mock + private ProductOptionRepository productOptionRepository; + + @BeforeEach + void setUp() { + productOptionService = new ProductOptionService(productOptionRepository); + productService = new ProductService(productRepository, categoryRepository, wishProductRepository, productOptionService); + } + + @Nested + @DisplayName("createProduct 메서드는") + class createProduct { + + @Test + @DisplayName("상품 생성 요청이 정상적일 때, 상품을 성공적으로 생성합니다.") + void when_valid_request() { + //given + CreateProductOptionRequest optionRequest1 = new CreateProductOptionRequest("옵션01", 100); + CreateProductOptionRequest optionRequest2 = new CreateProductOptionRequest("옵션02", 100); + CreateProductRequest request = new CreateProductRequest("상품01", 10000, + "https://www.google.com", 1L, List.of(optionRequest1)); + + Category category = new Builder() + .id(1L) + .name("카테고리01") + .description("카테고리01 설명") + .imageUrl(StringToUrlConverter.convert("https://www.google.com")) + .color(Color.from("#FFFFFF")) + .build(); + + Product product = request.toEntity(category); + given(categoryRepository.findById(any())).willReturn(Optional.of(category)); + given(productRepository.save(any())).willReturn(product); + + //when + CreateProductResponse response = productService.createProduct(request); + + //then + assertAll( + () -> assertThat(response.getName()).isEqualTo(product.getName()), + () -> assertThat(response.getPrice()).isEqualTo(product.getPrice()), + () -> assertThat(response.getImageUrl()).isEqualTo(product.getImageUrl().toString()), + + () -> assertThat(response.getOptions().size()).isEqualTo(product.getProductOptions().size()), + () -> assertThat(response.getOptions().get(0).getName()).isEqualTo(product.getProductOptions().get(0).getName()), + () -> assertThat(response.getOptions().get(0).getStock()).isEqualTo(product.getProductOptions().get(0).getStock()) + ); + } + + @Test + @DisplayName("상품 생성 시 옵션 이름에 중복이 존재하면 예외를 발생시킵니다.") + void when_duplicate_option_name() { + //given + CreateProductOptionRequest optionRequest1 = new CreateProductOptionRequest("옵션01", 100); + CreateProductOptionRequest optionRequest2 = new CreateProductOptionRequest("옵션01", 100); + CreateProductRequest request = new CreateProductRequest("상품01", 10000, + "https://www.google.com", 1L, List.of(optionRequest1, optionRequest2)); + + Category category = new Builder() + .id(1L) + .name("카테고리01") + .description("카테고리01 설명") + .imageUrl(StringToUrlConverter.convert("https://www.google.com")) + .color(Color.from("#FFFFFF")) + .build(); + + Product product = request.toEntity(category); + given(categoryRepository.findById(any())).willReturn(Optional.of(category)); + given(productRepository.save(any())).willReturn(product); + + //when + //then + assertThatThrownBy(() -> productService.createProduct(request)) + .isInstanceOf(IllegalStateException.class); + } + + } + + @Test + @DisplayName("상품 조회 요청이 정상적일 때, 상품을 성공적으로 조회합니다.") + void readProductById() { + //given + Product product = new Product.Builder() + .id(1L) + .name("상품01") + .price(10000) + .imageUrl(StringToUrlConverter.convert("https://www.google.com")) + .category(new Builder().id(1L).name("카테고리01").description("카테고리01 설명").imageUrl(StringToUrlConverter.convert("https://www.google.com")).color(Color.from("#FFFFFF")).build()) + .productOptions(List.of(new ProductOption.Builder().id(1L).name("옵션01").stock(100).build())) + .build(); + + given(productRepository.findById(any())).willReturn(Optional.of(product)); + + //when + ReadProductResponse response = productService.readProductById(1L); + + //then + assertAll( + () -> assertThat(response.getId()).isEqualTo(product.getId()), + () -> assertThat(response.getName()).isEqualTo(product.getName()), + () -> assertThat(response.getPrice()).isEqualTo(product.getPrice()), + () -> assertThat(response.getImageUrl()).isEqualTo(product.getImageUrl().toString()), + () -> assertThat(response.getCategory().getId()).isEqualTo(product.getCategory().getId()), + () -> assertThat(response.getCategory().getName()).isEqualTo(product.getCategory().getName()) + ); + } + + @Test + @DisplayName("카테고리 ID로 상품 조회 요청이 정상적일 때, 해당 카테고리에 속한 상품들을 성공적으로 조회합니다.") + void readProductsByCategoryId() { + //given + Category category = new Builder() + .id(1L) + .name("카테고리01") + .description("카테고리01 설명") + .imageUrl(StringToUrlConverter.convert("https://www.google.com")) + .color(Color.from("#FFFFFF")) + .build(); + + Product product01 = new Product.Builder() + .id(1L) + .name("상품01") + .price(10000) + .imageUrl(StringToUrlConverter.convert("https://www.google.com")) + .category(category) + .productOptions(List.of(new ProductOption.Builder().id(1L).name("옵션01").stock(100).build())) + .build(); + + Product product02 = new Product.Builder() + .id(2L) + .name("상품02") + .price(20000) + .imageUrl(StringToUrlConverter.convert("https://www.google.com")) + .category(category) + .productOptions(List.of(new ProductOption.Builder().id(2L).name("옵션02").stock(200).build())) + .build(); + + given(productRepository.findByCategoryId(any(), any())).willReturn(List.of(product01, product02)); + + //when + ReadAllProductsResponse response = productService.readProductsByCategoryId( + 1L, null); + + //then + assertAll( + () -> assertThat(response.getProducts().size()).isEqualTo(2), + + () -> assertThat(response.getProducts().get(0).getId()).isEqualTo(product01.getId()), + () -> assertThat(response.getProducts().get(0).getName()).isEqualTo(product01.getName()), + () -> assertThat(response.getProducts().get(0).getPrice()).isEqualTo(product01.getPrice()), + () -> assertThat(response.getProducts().get(0).getImageUrl()).isEqualTo(product01.getImageUrl().toString()), + () -> assertThat(response.getProducts().get(0).getCategory().getId()).isEqualTo(product01.getCategory().getId()), + () -> assertThat(response.getProducts().get(0).getCategory().getName()).isEqualTo(product01.getCategory().getName()), + + () -> assertThat(response.getProducts().get(1).getId()).isEqualTo(product02.getId()), + () -> assertThat(response.getProducts().get(1).getName()).isEqualTo(product02.getName()), + () -> assertThat(response.getProducts().get(1).getPrice()).isEqualTo(product02.getPrice()), + () -> assertThat(response.getProducts().get(1).getImageUrl()).isEqualTo(product02.getImageUrl().toString()), + () -> assertThat(response.getProducts().get(1).getCategory().getId()).isEqualTo(product02.getCategory().getId()), + () -> assertThat(response.getProducts().get(1).getCategory().getName()).isEqualTo(product02.getCategory().getName()) + ); + } + + @Test + @DisplayName("상품 수정 요청이 정상적일 때, 상품을 성공적으로 수정합니다.") + void updateProduct() { + //given + Product product = new Product.Builder() + .id(1L) + .name("상품01") + .price(10000) + .imageUrl(StringToUrlConverter.convert("https://www.google.com")) + .category(new Builder().id(1L).name("카테고리01").description("카테고리01 설명").imageUrl(StringToUrlConverter.convert("https://www.google.com")).color(Color.from("#FFFFFF")).build()) + .productOptions(List.of(new ProductOption.Builder().id(1L).name("옵션01").stock(100).build())) + .build(); + + given(productRepository.findById(any())).willReturn(Optional.of(product)); + + UpdateProductRequest request = new UpdateProductRequest("상품02", 20000, "https://www.naver.com"); + + //when + UpdateProductResponse response = productService.updateProduct(1L, request); + + //then + assertAll( + () -> assertThat(response.getId()).isEqualTo(product.getId()), + () -> assertThat(response.getName()).isEqualTo(request.getName()), + () -> assertThat(response.getPrice()).isEqualTo(request.getPrice()), + () -> assertThat(response.getImageUrl()).isEqualTo(request.getImageUrl()) + ); + } + + @Test + @DisplayName("상품 삭제 요청이 정상적일 때, 상품을 성공적으로 삭제합니다.") + void deleteProduct() { + //given + Product product = new Product.Builder() + .id(1L) + .name("상품01") + .price(10000) + .imageUrl(StringToUrlConverter.convert("https://www.google.com")) + .category(new Builder().id(1L).name("카테고리01").description("카테고리01 설명").imageUrl(StringToUrlConverter.convert("https://www.google.com")).color(Color.from("#FFFFFF")).build()) + .productOptions(List.of(new ProductOption.Builder().id(1L).name("옵션01").stock(100).build())) + .build(); + + given(productRepository.findById(any())).willReturn(Optional.of(product)); + + //when + //then + assertDoesNotThrow(() -> productService.deleteProduct(1L)); + } +} \ No newline at end of file diff --git a/src/test/java/gift/service/WishProductServiceTest.java b/src/test/java/gift/service/WishProductServiceTest.java new file mode 100644 index 000000000..381d0bb18 --- /dev/null +++ b/src/test/java/gift/service/WishProductServiceTest.java @@ -0,0 +1,173 @@ +package gift.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import gift.converter.StringToUrlConverter; +import gift.domain.Member; +import gift.domain.Product; +import gift.domain.ProductOption; +import gift.domain.WishProduct; +import gift.domain.WishProduct.Builder; +import gift.repository.MemberRepository; +import gift.repository.ProductRepository; +import gift.repository.WishProductRepository; +import gift.web.dto.request.wishproduct.CreateWishProductRequest; +import gift.web.dto.request.wishproduct.UpdateWishProductRequest; +import gift.web.dto.response.wishproduct.CreateWishProductResponse; +import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; +import gift.web.dto.response.wishproduct.UpdateWishProductResponse; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class WishProductServiceTest { + + @InjectMocks + private WishProductService wishProductService; + + @Mock + private WishProductRepository wishProductRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private ProductRepository productRepository; + + @Nested + @DisplayName("createWishProduct 메서드는") + class Describe_createWishProduct { + + @Test + @DisplayName("이미 위시 상품이 존재할 경우, 수량만 추가한다.") + void it_adds_quantity_when_wish_product_already_exists() { + // given + int beforeQuantity = 1; + WishProduct wishProduct = new Builder().product(null).member(null).quantity(beforeQuantity).build(); + given(wishProductRepository.findByMemberIdAndProductId(any(), any())) + .willReturn(Optional.of(wishProduct)); + + int addQuantity = 2; + CreateWishProductRequest request = new CreateWishProductRequest(1L, addQuantity); + + // when + CreateWishProductResponse response = wishProductService.createWishProduct(1L, request); + + // then + int expectedQuantity = beforeQuantity + addQuantity; + assertAll( + () -> assertThat(response.getId()).isEqualTo(wishProduct.getId()), + () -> assertThat(response.getQuantity()).isEqualTo(expectedQuantity) + ); + } + + @Test + @DisplayName("새로운 위시 상품을 추가한다.") + void it_adds_new_wish_product() { + // given + int quantity = 1; + CreateWishProductRequest request = new CreateWishProductRequest(1L, quantity); + given(wishProductRepository.findByMemberIdAndProductId(any(), any())) + .willReturn(Optional.empty()); + + given(wishProductRepository.save(any())) + .willReturn(new WishProduct.Builder().id(1L).quantity(quantity).build()); + + Member member = new Member.Builder().id(1L).build(); + given(memberRepository.findById(any())) + .willReturn(Optional.of(member)); + + Product product = new Product.Builder().id(1L).productOptions(List.of(new ProductOption.Builder().id(1L).build())).build(); + given(productRepository.findById(any())) + .willReturn(Optional.of(product)); + + // when + CreateWishProductResponse response = wishProductService.createWishProduct(1L, request); + + // then + assertAll( + () -> assertThat(response.getId()).isNotNull(), + () -> assertThat(response.getQuantity()).isEqualTo(quantity) + ); + } + } + + @Test + @DisplayName("정상 요청일 때, 위시 리스트에 있는 모든 상품을 조회한다.") + void readAllWishProducts() { + //given + Product product01 = new Product.Builder().id(1L).name("상품01").imageUrl(StringToUrlConverter.convert("https://www.google.com")).price(1000).productOptions(List.of(new ProductOption.Builder().id(1L).build())).build(); + Product product02 = new Product.Builder().id(2L).name("상품02").imageUrl(StringToUrlConverter.convert("https://www.google.com")).price(2000).productOptions(List.of(new ProductOption.Builder().id(2L).build())).build(); + + WishProduct wishProduct01 = new Builder().id(1L).quantity(1).product(product01).build(); + WishProduct wishProduct02 = new Builder().id(2L).quantity(2).product(product02).build(); + + List wishProducts = List.of(wishProduct01, wishProduct02); + given(wishProductRepository.findByMemberId(any(), any())) + .willReturn(wishProducts); + + //when + ReadAllWishProductsResponse response = wishProductService.readAllWishProducts(1L, null); + + //then + assertAll( + () -> assertThat(response.getWishlist().size()).isEqualTo(wishProducts.size()), + () -> assertThat(response.getWishlist().get(0).getId()).isEqualTo(wishProduct01.getId()), + () -> assertThat(response.getWishlist().get(0).getQuantity()).isEqualTo(wishProduct01.getQuantity()), + () -> assertThat(response.getWishlist().get(0).getProductId()).isEqualTo(wishProduct01.getProduct().getId()), + () -> assertThat(response.getWishlist().get(0).getName()).isEqualTo(wishProduct01.getProduct().getName()), + () -> assertThat(response.getWishlist().get(0).getImageUrl()).isEqualTo(wishProduct01.getProduct().getImageUrl().toString()), + () -> assertThat(response.getWishlist().get(0).getPrice()).isEqualTo(wishProduct01.getProduct().getPrice()), + () -> assertThat(response.getWishlist().get(1).getId()).isEqualTo(wishProduct02.getId()), + () -> assertThat(response.getWishlist().get(1).getQuantity()).isEqualTo(wishProduct02.getQuantity()), + () -> assertThat(response.getWishlist().get(1).getId()).isEqualTo(wishProduct02.getProduct().getId()), + () -> assertThat(response.getWishlist().get(1).getName()).isEqualTo(wishProduct02.getProduct().getName()), + () -> assertThat(response.getWishlist().get(1).getImageUrl()).isEqualTo(wishProduct02.getProduct().getImageUrl().toString()), + () -> assertThat(response.getWishlist().get(1).getPrice()).isEqualTo(wishProduct02.getProduct().getPrice()) + ); + } + + @Test + @DisplayName("정상 요청일 때, 위시 리스트에 있는 상품의 수량을 수정한다.") + void updateWishProduct() { + //given + Product product01 = new Product.Builder().id(1L).name("상품01").imageUrl(StringToUrlConverter.convert("https://www.google.com")).price(1000).productOptions(List.of(new ProductOption.Builder().id(1L).build())).build(); + WishProduct wishProduct01 = new Builder().id(1L).quantity(1).product(product01).build(); + given(wishProductRepository.findById(any())) + .willReturn(Optional.of(wishProduct01)); + + int quantity = 5; + UpdateWishProductRequest request = new UpdateWishProductRequest(quantity); + //when + UpdateWishProductResponse response = wishProductService.updateWishProduct(1L, request); + + //then + assertAll( + () -> assertThat(response.getId()).isEqualTo(wishProduct01.getId()), + () -> assertThat(response.getQuantity()).isEqualTo(quantity) + ); + } + + @Test + @DisplayName("정상 요청일 때, 위시 리스트에 있는 상품을 삭제한다.") + void deleteWishProduct() { + //given + WishProduct wishProduct = new Builder().id(1L).build(); + given(wishProductRepository.findById(any())) + .willReturn(Optional.of(wishProduct)); + + //when + assertDoesNotThrow(() -> wishProductService.deleteWishProduct(1L)); + } +} \ No newline at end of file diff --git a/src/test/java/gift/utils/CategoryDummyDataProvider.java b/src/test/java/gift/utils/CategoryDummyDataProvider.java new file mode 100644 index 000000000..2773c3497 --- /dev/null +++ b/src/test/java/gift/utils/CategoryDummyDataProvider.java @@ -0,0 +1,51 @@ +package gift.utils; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Profile("test") +@Component +public class CategoryDummyDataProvider { + + private final JdbcTemplate jdbcTemplate; + + public CategoryDummyDataProvider(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void run(int quantity) { + doRun(quantity); + } + + private void doRun(int quantity) { + String sql = "insert into category (name, description, image_url, color, created_at, created_by, updated_at, updated_by) values (?, ?, ?, ?, ?, ?, ?, ?)"; + jdbcTemplate.batchUpdate(sql, getBatchPreparedStatementSetter(quantity)); + } + + private static BatchPreparedStatementSetter getBatchPreparedStatementSetter(int quantity) { + return new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ps.setString(1, "Category" + i); + ps.setString(2, "Description" + i); + ps.setString(3, "https://via.placeholder.com/" + i); + ps.setString(4, "#FF2829"); + ps.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now())); + ps.setLong(6, 1L); + ps.setTimestamp(7, Timestamp.valueOf(LocalDateTime.now())); + ps.setLong(8, 1L); + } + + @Override + public int getBatchSize() { + return quantity; + } + }; + } +} \ No newline at end of file diff --git a/src/test/java/gift/utils/DatabaseCleanup.java b/src/test/java/gift/utils/DatabaseCleanup.java new file mode 100644 index 000000000..46ea412f2 --- /dev/null +++ b/src/test/java/gift/utils/DatabaseCleanup.java @@ -0,0 +1,41 @@ +package gift.utils; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.List; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Profile("test") +public class DatabaseCleanup implements InitializingBean { + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @Override + public void afterPropertiesSet() { + tableNames = entityManager.getMetamodel().getEntities().stream() + .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null) + .map(e -> SnakeCaseStrategy.INSTANCE.translate(e.getName())) + .toList(); + } + + @Transactional + public void execute() { + entityManager.flush(); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + + for (String tableName : tableNames) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN id RESTART WITH 1").executeUpdate(); + } + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } +} diff --git a/src/test/java/gift/utils/MemberDummyDataProvider.java b/src/test/java/gift/utils/MemberDummyDataProvider.java new file mode 100644 index 000000000..cf340e8c2 --- /dev/null +++ b/src/test/java/gift/utils/MemberDummyDataProvider.java @@ -0,0 +1,48 @@ +package gift.utils; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Profile("test") +@Component +public class MemberDummyDataProvider { + + private final JdbcTemplate jdbcTemplate; + + public MemberDummyDataProvider(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void run(int quantity) { + doRun(quantity); + } + + private void doRun(int quantity) { + String sql = "insert into member (name, email, password, created_at, updated_at) values (?, ?, ?, ?, ?)"; + jdbcTemplate.batchUpdate(sql, getBatchPreparedStatementSetter(quantity)); + } + + private static BatchPreparedStatementSetter getBatchPreparedStatementSetter(int quantity) { + return new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ps.setString(1, "member" + i); + ps.setString(2, "member" + i + "@gmail.com"); + ps.setString(3, "member" + i + "0"); + ps.setTimestamp(4, Timestamp.valueOf(LocalDateTime.now())); + ps.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now())); + } + + @Override + public int getBatchSize() { + return quantity; + } + }; + } +} \ No newline at end of file diff --git a/src/test/java/gift/utils/ProductDummyDataProvider.java b/src/test/java/gift/utils/ProductDummyDataProvider.java new file mode 100644 index 000000000..42ca87d0a --- /dev/null +++ b/src/test/java/gift/utils/ProductDummyDataProvider.java @@ -0,0 +1,50 @@ +package gift.utils; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Profile("test") +@Component +public class ProductDummyDataProvider { + + private final JdbcTemplate jdbcTemplate; + + public ProductDummyDataProvider(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void run(int quantity) { + doRun(quantity); + } + + private void doRun(int quantity) { + String sql = "insert into product (name, price, category_id, created_at, created_by, updated_at, updated_by) values (?, ?, ?, ?, ?, ?, ?)"; + jdbcTemplate.batchUpdate(sql, getBatchPreparedStatementSetter(quantity)); + } + + private static BatchPreparedStatementSetter getBatchPreparedStatementSetter(int quantity) { + return new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ps.setString(1, "product" + i); + ps.setInt(2, 1000 * i); + ps.setLong(3, 1L); + ps.setTimestamp(4, Timestamp.valueOf(LocalDateTime.now())); + ps.setLong(5, 1L); + ps.setTimestamp(6, Timestamp.valueOf(LocalDateTime.now())); + ps.setLong(7, 1L); + } + + @Override + public int getBatchSize() { + return quantity; + } + }; + } +} \ No newline at end of file diff --git a/src/test/java/gift/utils/StringUtilsTest.java b/src/test/java/gift/utils/StringUtilsTest.java new file mode 100644 index 000000000..8a818eb53 --- /dev/null +++ b/src/test/java/gift/utils/StringUtilsTest.java @@ -0,0 +1,71 @@ +package gift.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class StringUtilsTest { + + @ParameterizedTest(name = "{0}의 특수문자는 {1} 만을 포함하고 있어야 한다") + @MethodSource("specialCharsSuccessCase") + void containsOnlyAllowedSpecialCharsSuccessCase(String input, Set allowedSpecialChars) { + assertTrue(StringUtils.containsOnlyAllowedSpecialChars(input, allowedSpecialChars)); + } + + private static Stream specialCharsSuccessCase() { + return Stream.of( + Arguments.of("abc", Set.of()), + Arguments.of("abc", Set.of('!', '@', '#', '$', '%')), + Arguments.of("ab!c!@#", Set.of('!', '@', '#', '$', '%')), + Arguments.of("abc!@#%", Set.of('!', '@', '#', '$', '%')), + Arguments.of("a@bc!#%$", Set.of('!', '@', '#', '$', '%')), + Arguments.of("ab!c!@#%$", Set.of('!', '@', '#', '$', '%')), + Arguments.of("ab^c!@#%$^&", Set.of('!', '@', '#', '$', '%', '^', '&')), + Arguments.of("abc!@#%$^&*", Set.of('!', '@', '#', '$', '%', '^', '&', '*'))); + } + + @ParameterizedTest(name = "{0}의 특수문자는 {1} 외의 특수문자를 포함하고 있다") + @MethodSource("specialCharsFailCase") + void containsOnlyAllowedSpecialCharsFailCase(String input, Set allowedSpecialChars) { + assertFalse(StringUtils.containsOnlyAllowedSpecialChars(input, allowedSpecialChars)); + } + + private static Stream specialCharsFailCase() { + return Stream.of( + Arguments.of("abc!", Set.of()), + Arguments.of("a!b%!%c$", Set.of('!', '%')), + Arguments.of("ab^c!@#%$^&)", Set.of('!', '@', '#', '$', '%', '^')), + Arguments.of("abc!@#%$^&*[", Set.of('!', '@', '#', '$', '%', '^', '&'))); + } + + @ParameterizedTest(name = "{0}는 {1}를 포함하고 있다") + @MethodSource("substringsSuccessCase") + void containsAnySubstringSuccessCase(String input, Set substrings) { + assertTrue(StringUtils.containsAnySubstring(input, substrings)); + } + + private static Stream substringsSuccessCase() { + return Stream.of( + Arguments.of("abc", Set.of("ab")), + Arguments.of("카카오 선풍기", Set.of("카카오")), + Arguments.of("카카오", Set.of("카카오"))); + } + + @ParameterizedTest(name = "{0}는 {1}를 포함하고 있지 않다") + @MethodSource("substringsFailCase") + void containsAnySubstringFailCase(String input, Set substrings) { + assertFalse(StringUtils.containsAnySubstring(input, substrings)); + } + + private static Stream substringsFailCase() { + return Stream.of( + Arguments.of("abcdefg", Set.of("aefg")), + Arguments.of("카카오 선풍기", Set.of("네이버")), + Arguments.of("카카오 선풍기", Set.of("카카오선풍기")) + ); + } +} \ No newline at end of file diff --git a/src/test/java/gift/utils/WishProductDummyDataProvider.java b/src/test/java/gift/utils/WishProductDummyDataProvider.java new file mode 100644 index 000000000..eae21073a --- /dev/null +++ b/src/test/java/gift/utils/WishProductDummyDataProvider.java @@ -0,0 +1,52 @@ +package gift.utils; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Profile("test") +@Component +public class WishProductDummyDataProvider { + + private final JdbcTemplate jdbcTemplate; + + private static final Long TARGET_MEMBER_ID = 1L; + + public WishProductDummyDataProvider(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void run(int quantity) { + doRun(quantity); + } + + private void doRun(int quantity) { + String sql = "insert into wish_product (member_id, product_id, quantity, created_at, created_by, updated_at, updated_by) values (?, ?, ?, ?, ?, ?, ?)"; + jdbcTemplate.batchUpdate(sql, getBatchPreparedStatementSetter(quantity)); + } + + private static BatchPreparedStatementSetter getBatchPreparedStatementSetter(int quantity) { + return new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ps.setLong(1, TARGET_MEMBER_ID); + ps.setLong(2, i + 1); + ps.setInt(3, i + 1); + ps.setTimestamp(4, Timestamp.valueOf(LocalDateTime.now())); + ps.setLong(5, TARGET_MEMBER_ID); + ps.setTimestamp(6, Timestamp.valueOf(LocalDateTime.now())); + ps.setLong(7, TARGET_MEMBER_ID); + } + + @Override + public int getBatchSize() { + return quantity; + } + }; + } +} diff --git a/src/test/java/gift/web/controller/api/MemberApiControllerTest.java b/src/test/java/gift/web/controller/api/MemberApiControllerTest.java new file mode 100644 index 000000000..1dbea41d9 --- /dev/null +++ b/src/test/java/gift/web/controller/api/MemberApiControllerTest.java @@ -0,0 +1,226 @@ +package gift.web.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gift.authentication.token.Token; +import gift.domain.Member; +import gift.repository.MemberRepository; +import gift.service.MemberService; +import gift.service.WishProductService; +import gift.utils.CategoryDummyDataProvider; +import gift.utils.DatabaseCleanup; +import gift.utils.MemberDummyDataProvider; +import gift.utils.ProductDummyDataProvider; +import gift.utils.WishProductDummyDataProvider; +import gift.web.dto.request.LoginRequest; +import gift.web.dto.request.member.CreateMemberRequest; +import gift.web.dto.request.wishproduct.UpdateWishProductRequest; +import gift.web.dto.response.LoginResponse; +import gift.web.dto.response.member.CreateMemberResponse; +import gift.web.dto.response.member.ReadMemberResponse; +import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; +import gift.web.dto.response.wishproduct.UpdateWishProductResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class MemberApiControllerTest { + + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private MemberDummyDataProvider memberDummyDataProvider; + + @Autowired + private ProductDummyDataProvider productDummyDataProvider; + + @Autowired + private WishProductDummyDataProvider wishProductDummyDataProvider; + + @Autowired + private CategoryDummyDataProvider categoryDummyDataProvider; + + @Autowired + private DatabaseCleanup databaseCleanup; + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private WishProductService wishProductService; + + //테스트용 회원 + private Member member; + private Token token; + + @BeforeEach + void setUp() { + insertDummyData(100); + member = getTestMember(1L); + token = getAccessToken(); + } + + private Member getTestMember(Long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("ID: " + id +"인 회원이 존재하지 않습니다.")); + } + + private Token getAccessToken() { + LoginRequest loginRequest = new LoginRequest( + member.getEmail().getValue(), + member.getPassword().getValue() + ); + LoginResponse loginResponse = memberService.login(loginRequest); + return loginResponse.getToken(); + } + + private void insertDummyData(int quantity) { + if (quantity < 2) { + throw new IllegalArgumentException("quantity는 2 이상이어야 합니다."); + } + memberDummyDataProvider.run(quantity); + productDummyDataProvider.run(quantity); + wishProductDummyDataProvider.run(quantity); + categoryDummyDataProvider.run(quantity); + } + + @AfterEach + void tearDown() { + databaseCleanup.execute(); + } + + @Test + @DisplayName("회원 생성 요청에 대한 정상 응답") + void createMember() { + //given + CreateMemberRequest request = new CreateMemberRequest("test@gmail.com", "test1234", "test"); + String url = "http://localhost:" + port + "/api/members/register"; + + //when + ResponseEntity response = restTemplate.postForEntity(url, request, CreateMemberResponse.class); + + //then + Long newMemberId = response.getBody().getId(); + ReadMemberResponse findMember = memberService.readMember(newMemberId); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(newMemberId).isEqualTo(findMember.getId()), + () -> assertThat(request.getEmail()).isEqualTo(findMember.getEmail()), + () -> assertThat(request.getName()).isEqualTo(findMember.getName()), + () -> assertThat(request.getPassword()).isEqualTo(findMember.getPassword()) + ); + } + + @Test + @DisplayName("로그인 요청에 대한 정상 응답") + void login() { + //given + String url = "http://localhost:" + port + "/api/members/login"; + String email = member.getEmail().getValue(); + String password = member.getPassword().getValue(); + LoginRequest request = new LoginRequest(email, password); + + //when + ResponseEntity response = restTemplate.postForEntity(url, request, LoginResponse.class); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().getToken()).isNotNull() + ); + } + + @Test + @DisplayName("위시 리스트 조회 요청에 대한 정상 응답") + void readWishProduct() { + //given + String url = "http://localhost:" + port + "/api/members/wishlist"; + HttpHeaders httpHeaders = getHttpHeaders(); + HttpEntity httpEntity = new HttpEntity(httpHeaders); + + PageRequest defaultPageRequest = PageRequest.of(0, 10); + ReadAllWishProductsResponse expectedWishProducts = wishProductService.readAllWishProducts(member.getId(), + defaultPageRequest); + + //when + ResponseEntity response = restTemplate.exchange(url, + HttpMethod.GET, httpEntity, ReadAllWishProductsResponse.class); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertIterableEquals(response.getBody().getWishlist(), expectedWishProducts.getWishlist()) + ); + } + + @Test + @DisplayName("위시 리스트 상품 수정 요청에 대한 정상 응답") + void updateWishProduct() { + //given + Long wishProductId = 1L; + String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; + HttpHeaders httpHeaders = getHttpHeaders(); + + UpdateWishProductRequest request = new UpdateWishProductRequest(3); + + HttpEntity httpEntity = new HttpEntity(request, httpHeaders); + + //when + ResponseEntity response = restTemplate.exchange(url, + HttpMethod.PUT, httpEntity, UpdateWishProductResponse.class); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().getQuantity()).isEqualTo(request.getQuantity()) + ); + } + + @Test + @DisplayName("위시 리스트 상품 삭제 요청에 대한 정상 응답") + void deleteWishProduct() { + //given + Long wishProductId = 2L; + String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; + HttpHeaders httpHeaders = getHttpHeaders(); + HttpEntity httpEntity = new HttpEntity(httpHeaders); + + //when + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, httpEntity, + Void.class); + + //then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + private HttpHeaders getHttpHeaders() { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setBearerAuth(token.getValue()); + return httpHeaders; + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 000000000..603334cd8 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,28 @@ +spring: + datasource: + url: jdbc:h2:mem:gift + username: sa + password: + driver-class-name: org.h2.Driver + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + defer-datasource-initialization: true #Hibernate 초기화 후 data.sql 실행 + +jwt: + expiration: 3600000 #1시간 + secretkey: 994e69b6df494d3e5f48fdb32693d5c772cc35a4d4f67cce4323fe256b6caf31b666d21db4b9f974935c7900bce11947edf8e266ef87aaa176db591b220860ff + +logging: + level: + gift.authentication: debug + org: + hibernate: + SQL: debug + orm: + jdbc: + bind: trace \ No newline at end of file From e9daf63170ddb8b08a79ad1914e9af6c241bca47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:00:50 +0900 Subject: [PATCH 02/80] =?UTF-8?q?docs:=20README.md=20Step5=201=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8bbd0354f..42b1f59e0 100644 --- a/README.md +++ b/README.md @@ -1 +1,13 @@ -# spring-gift-order \ No newline at end of file +# spring-gift-order +## Step 5 +*** +### 🚀 1단계 - 기본 코드 준비 +*** +#### 기능 요구 사항 +카카오 로그인을 통해 인가 코드를 받고, 인가 코드를 사용해 토큰을 받은 후 향후 카카오 API 사용을 준비한다. + +* 카카오계정 로그인을 통해 인증 코드를 받는다. +* 토큰 받기를 읽고 액세스 토큰을 추출한다. +* 앱 키, 인가 코드가 절대 유출되지 않도록 한다. + * 특히 시크릿 키는 GitHub나 클라이언트 코드 등 외부에서 볼 수 있는 곳에 추가하지 않는다. +* (선택) 인가 코드를 받는 방법이 불편한 경우 카카오 로그인 화면을 구현한다. \ No newline at end of file From 7efd22997c904fdecc17084d1a0e219187c2aae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:16:39 +0900 Subject: [PATCH 03/80] =?UTF-8?q?chore:=20build.gradle=20OpenFeign=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.gradle b/build.gradle index 9ffb4a4b1..3b37a3492 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + // OpenFeign + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' //jwt implementation 'io.jsonwebtoken:jjwt-api:0.12.6' @@ -34,6 +36,12 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.3" + } +} + tasks.named('test') { useJUnitPlatform() } From 0a0504e4305826b991623a35b3cd2ff2859f9350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:17:04 +0900 Subject: [PATCH 04/80] =?UTF-8?q?feat:=20KakaoClient.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gift/web/client/KakaoClient.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/gift/web/client/KakaoClient.java diff --git a/src/main/java/gift/web/client/KakaoClient.java b/src/main/java/gift/web/client/KakaoClient.java new file mode 100644 index 000000000..401e1c28e --- /dev/null +++ b/src/main/java/gift/web/client/KakaoClient.java @@ -0,0 +1,26 @@ +package gift.web.client; + +import gift.authentication.token.KakaoToken; +import gift.web.client.dto.KakaoInfo; +import java.net.URI; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "kakaoClient") +public interface KakaoClient { + + @PostMapping + KakaoInfo getKakaoInfo( + URI uri, + @RequestHeader("Authorization") String accessToken); + + @PostMapping + KakaoToken getToken( + URI uri, + @RequestParam("code") String code, + @RequestParam("client_id") String clientId, + @RequestParam("redirect_uri") String redirectUrl, + @RequestParam("grant_type") String grantType); +} From 1d4ff4dd6c857bb3382682cb262325ba464feb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:17:19 +0900 Subject: [PATCH 05/80] =?UTF-8?q?feat:=20KakaoInfo.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gift/web/client/dto/KakaoInfo.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/gift/web/client/dto/KakaoInfo.java diff --git a/src/main/java/gift/web/client/dto/KakaoInfo.java b/src/main/java/gift/web/client/dto/KakaoInfo.java new file mode 100644 index 000000000..8452382e6 --- /dev/null +++ b/src/main/java/gift/web/client/dto/KakaoInfo.java @@ -0,0 +1,24 @@ +package gift.web.client.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public class KakaoInfo { + + private Long id; + private KakaoAccount kakaoAccount; + + public KakaoInfo(Long id, KakaoAccount kakaoAccount) { + this.id = id; + this.kakaoAccount = kakaoAccount; + } + + public Long getId() { + return id; + } + + public KakaoAccount getKakaoAccount() { + return kakaoAccount; + } +} From 3e12882aecf973551dde873d21eea80b21a93a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:17:25 +0900 Subject: [PATCH 06/80] =?UTF-8?q?feat:=20KakaoProfile.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gift/web/client/dto/KakaoProfile.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/java/gift/web/client/dto/KakaoProfile.java diff --git a/src/main/java/gift/web/client/dto/KakaoProfile.java b/src/main/java/gift/web/client/dto/KakaoProfile.java new file mode 100644 index 000000000..b77e11e1f --- /dev/null +++ b/src/main/java/gift/web/client/dto/KakaoProfile.java @@ -0,0 +1,17 @@ +package gift.web.client.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public class KakaoProfile { + + private String nickname; + + @JsonCreator + public KakaoProfile(String nickname) { + this.nickname = nickname; + } + + public String getNickname() { + return nickname; + } +} From f9ebe69a2a3ba9513c151b73ba522e1dbe8190b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:17:32 +0900 Subject: [PATCH 07/80] =?UTF-8?q?feat:=20KakaoProperties.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gift/config/KakaoProperties.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/main/java/gift/config/KakaoProperties.java diff --git a/src/main/java/gift/config/KakaoProperties.java b/src/main/java/gift/config/KakaoProperties.java new file mode 100644 index 000000000..baa7b457c --- /dev/null +++ b/src/main/java/gift/config/KakaoProperties.java @@ -0,0 +1,56 @@ +package gift.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +@ConfigurationProperties(prefix = "kakao") +public class KakaoProperties { + + private final String clientId; + private final String redirectUri; + private final String contentType; + private final String grantType; + private final String userInfoUrl; + private final String tokenUrl; + private final String responseType; + + @ConstructorBinding + public KakaoProperties(String clientId, String redirectUri, String contentType, + String grantType, String userInfoUrl, String tokenUrl, String responseType) { + this.clientId = clientId; + this.redirectUri = redirectUri; + this.contentType = contentType; + this.grantType = grantType; + this.userInfoUrl = userInfoUrl; + this.tokenUrl = tokenUrl; + this.responseType = responseType; + } + + public String getClientId() { + return clientId; + } + + public String getRedirectUri() { + return redirectUri; + } + + public String getContentType() { + return contentType; + } + + public String getGrantType() { + return grantType; + } + + public String getUserInfoUrl() { + return userInfoUrl; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public String getResponseType() { + return responseType; + } +} From e52db548562d33a3a575a02c72d629b87378a7f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:17:39 +0900 Subject: [PATCH 08/80] =?UTF-8?q?feat:=20KakaoToken.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/authentication/token/KakaoToken.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/main/java/gift/authentication/token/KakaoToken.java diff --git a/src/main/java/gift/authentication/token/KakaoToken.java b/src/main/java/gift/authentication/token/KakaoToken.java new file mode 100644 index 000000000..0d48863ed --- /dev/null +++ b/src/main/java/gift/authentication/token/KakaoToken.java @@ -0,0 +1,43 @@ +package gift.authentication.token; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public class KakaoToken { + + private final String tokenType; + private final String accessToken; + private final String refreshToken; + private final Long expiresIn; + private final Long refreshTokenExpiresIn; + + public KakaoToken(String tokenType, String accessToken, String refreshToken, Long expiresIn, + Long refreshTokenExpiresIn) { + this.tokenType = tokenType; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiresIn = expiresIn; + this.refreshTokenExpiresIn = refreshTokenExpiresIn; + } + + public String getTokenType() { + return tokenType; + } + + public String getAccessToken() { + return accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public Long getExpiresIn() { + return expiresIn; + } + + public Long getRefreshTokenExpiresIn() { + return refreshTokenExpiresIn; + } +} From 6f3cf6754759e0c4b6a88231042c904c79b9e48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:18:18 +0900 Subject: [PATCH 09/80] =?UTF-8?q?feat:=20KakaoAccount.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/web/client/dto/KakaoAccount.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/gift/web/client/dto/KakaoAccount.java diff --git a/src/main/java/gift/web/client/dto/KakaoAccount.java b/src/main/java/gift/web/client/dto/KakaoAccount.java new file mode 100644 index 000000000..c9c382c79 --- /dev/null +++ b/src/main/java/gift/web/client/dto/KakaoAccount.java @@ -0,0 +1,33 @@ +package gift.web.client.dto; + +public class KakaoAccount { + + private KakaoProfile profile; + private String email; + private Boolean isEmailValid; + private Boolean isEmailVerified; + + public KakaoAccount(KakaoProfile profile, String email, Boolean isEmailValid, + Boolean isEmailVerified) { + this.profile = profile; + this.email = email; + this.isEmailValid = isEmailValid; + this.isEmailVerified = isEmailVerified; + } + + public KakaoProfile getProfile() { + return profile; + } + + public String getEmail() { + return email; + } + + public Boolean getEmailValid() { + return isEmailValid; + } + + public Boolean getEmailVerified() { + return isEmailVerified; + } +} From e1ea3bb3b83ef1864cd87f953e7f46fdddca7477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:18:30 +0900 Subject: [PATCH 10/80] =?UTF-8?q?feat:=20LoginService.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/service/LoginService.java | 76 ++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/main/java/gift/service/LoginService.java diff --git a/src/main/java/gift/service/LoginService.java b/src/main/java/gift/service/LoginService.java new file mode 100644 index 000000000..03ec44394 --- /dev/null +++ b/src/main/java/gift/service/LoginService.java @@ -0,0 +1,76 @@ +package gift.service; + +import gift.authentication.token.JwtProvider; +import gift.authentication.token.KakaoToken; +import gift.authentication.token.Token; +import gift.config.KakaoProperties; +import gift.domain.Member; +import gift.web.client.KakaoClient; +import gift.web.client.dto.KakaoAccount; +import gift.web.client.dto.KakaoInfo; +import gift.web.dto.response.LoginResponse; +import gift.web.validation.exception.client.InvalidCredentialsException; +import java.net.URI; +import java.net.URISyntaxException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class LoginService { + + private final KakaoClient kakaoClient; + + private final KakaoProperties kakaoProperties; + + private final MemberService memberService; + + private final JwtProvider jwtProvider; + + public LoginService(KakaoClient kakaoClient, KakaoProperties kakaoProperties, + MemberService memberService, JwtProvider jwtProvider) { + this.kakaoClient = kakaoClient; + this.kakaoProperties = kakaoProperties; + this.memberService = memberService; + this.jwtProvider = jwtProvider; + } + + @Transactional + public LoginResponse kakaoLogin(final String authorizationCode){ + final KakaoToken kakaoToken = getToken(authorizationCode); + final KakaoInfo kakaoInfo = getInfo(kakaoToken); + + final KakaoAccount kakaoAccount = kakaoInfo.getKakaoAccount(); + final Member member = memberService.findOrCreateMember(kakaoAccount); + + final Token accessToken = jwtProvider.generateToken(member, kakaoToken.getAccessToken()); + + return new LoginResponse(accessToken.getValue()); + } + + private KakaoToken getToken(String authorizationCode) { + try { + return kakaoClient.getToken( + new URI(kakaoProperties.getTokenUrl()), + authorizationCode, + kakaoProperties.getClientId(), + kakaoProperties.getRedirectUri(), + kakaoProperties.getGrantType()); + } catch (URISyntaxException e) { + throw new InvalidCredentialsException(e); + } + } + + private KakaoInfo getInfo(KakaoToken kakaoToken) { + try { + return kakaoClient.getKakaoInfo( + new URI(kakaoProperties.getUserInfoUrl()), + getBearerToken(kakaoToken)); + } catch (URISyntaxException e) { + throw new InvalidCredentialsException(e); + } + } + + private String getBearerToken(KakaoToken kakaoToken) { + return kakaoToken.getTokenType() + " " + kakaoToken.getAccessToken(); + } +} From 12cfa989e567ecb7a972103e399559175f7298e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:19:09 +0900 Subject: [PATCH 11/80] =?UTF-8?q?feat:=20LoginResponse.java=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gift/web/dto/response/LoginResponse.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/gift/web/dto/response/LoginResponse.java b/src/main/java/gift/web/dto/response/LoginResponse.java index bdb8b444a..90173ab68 100644 --- a/src/main/java/gift/web/dto/response/LoginResponse.java +++ b/src/main/java/gift/web/dto/response/LoginResponse.java @@ -1,19 +1,18 @@ package gift.web.dto.response; -import gift.authentication.token.Token; - public class LoginResponse { - private Token token; + private String accessToken; private LoginResponse() { } - public LoginResponse(Token token) { - this.token = token; + public LoginResponse(String accessToken) { + this.accessToken = accessToken; } - public Token getToken() { - return token; + public String getAccessToken() { + return accessToken; } + } From 2fb0b8d7ee520ffccef354c694dbb5e65f328cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:19:51 +0900 Subject: [PATCH 12/80] =?UTF-8?q?feat:=20MemberService.java=20findOrCreate?= =?UTF-8?q?Member()=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/service/MemberService.java | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/main/java/gift/service/MemberService.java b/src/main/java/gift/service/MemberService.java index a2aef65cd..f092f2130 100644 --- a/src/main/java/gift/service/MemberService.java +++ b/src/main/java/gift/service/MemberService.java @@ -1,17 +1,13 @@ package gift.service; -import gift.authentication.token.JwtProvider; -import gift.authentication.token.Token; import gift.domain.Member; import gift.domain.vo.Email; import gift.repository.MemberRepository; import gift.repository.WishProductRepository; -import gift.web.dto.request.LoginRequest; +import gift.web.client.dto.KakaoAccount; import gift.web.dto.request.member.CreateMemberRequest; -import gift.web.dto.response.LoginResponse; import gift.web.dto.response.member.CreateMemberResponse; import gift.web.dto.response.member.ReadMemberResponse; -import gift.web.validation.exception.client.IncorrectEmailException; import java.util.NoSuchElementException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,14 +18,10 @@ public class MemberService { private final MemberRepository memberRepository; private final WishProductRepository wishProductRepository; - private final JwtProvider jwtProvider; - public MemberService(MemberRepository memberRepository, - WishProductRepository wishProductRepository, - JwtProvider jwtProvider) { + public MemberService(MemberRepository memberRepository, WishProductRepository wishProductRepository) { this.memberRepository = memberRepository; this.wishProductRepository = wishProductRepository; - this.jwtProvider = jwtProvider; } @Transactional @@ -45,20 +37,15 @@ public ReadMemberResponse readMember(Long id) { return ReadMemberResponse.fromEntity(member); } + public Member findOrCreateMember(KakaoAccount kakaoAccount) { + String email = kakaoAccount.getEmail(); + return memberRepository.findByEmail(Email.from(email)) + .orElseGet(() -> memberRepository.save(Member.from(kakaoAccount))); + } + public void deleteMember(Long id) { Member member = memberRepository.findById(id).orElseThrow(NoSuchElementException::new); wishProductRepository.deleteAllByMemberId(id); memberRepository.delete(member); } - - public LoginResponse login(LoginRequest request) { - Email email = Email.from(request.getEmail()); - Member member = memberRepository.findByEmail(email).orElseThrow(() -> new IncorrectEmailException(email.getValue())); - - member.matchPassword(request.getPassword()); - - Token token = jwtProvider.generateToken(member); - - return new LoginResponse(token); - } } From 6f2c4b10b536bc97019f4a522c526817c5bf82c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:22:18 +0900 Subject: [PATCH 13/80] =?UTF-8?q?feat:=20Member.java=20Password=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/domain/Member.java | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/main/java/gift/domain/Member.java b/src/main/java/gift/domain/Member.java index bdc25c983..9807a8417 100644 --- a/src/main/java/gift/domain/Member.java +++ b/src/main/java/gift/domain/Member.java @@ -2,8 +2,6 @@ import gift.domain.base.BaseTimeEntity; import gift.domain.vo.Email; -import gift.domain.vo.Password; -import gift.web.validation.exception.client.IncorrectPasswordException; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -14,9 +12,6 @@ public class Member extends BaseTimeEntity { @Embedded private Email email; - @Embedded - private Password password; - @Column(nullable = false) private String name; @@ -26,7 +21,6 @@ protected Member() { public static class Builder extends BaseTimeEntity.Builder { private Email email; - private Password password; private String name; public Builder email(Email email) { @@ -34,11 +28,6 @@ public Builder email(Email email) { return this; } - public Builder password(Password password) { - this.password = password; - return this; - } - public Builder name(String name) { this.name = name; return this; @@ -58,7 +47,6 @@ public Member build() { private Member(Builder builder) { super(builder); email = builder.email; - password = builder.password; name = builder.name; } @@ -66,17 +54,7 @@ public Email getEmail() { return email; } - public Password getPassword() { - return password; - } - public String getName() { return name; } - - public void matchPassword(String password) { - if (!this.password.matches(password)) { - throw new IncorrectPasswordException(); - } - } } From 1102c74dadbf24d14b7ee66f398505342a5a4a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:22:37 +0900 Subject: [PATCH 14/80] =?UTF-8?q?feat:=20KakaoAccount.java=20toMember()=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/web/client/dto/KakaoAccount.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/gift/web/client/dto/KakaoAccount.java b/src/main/java/gift/web/client/dto/KakaoAccount.java index c9c382c79..915204a4e 100644 --- a/src/main/java/gift/web/client/dto/KakaoAccount.java +++ b/src/main/java/gift/web/client/dto/KakaoAccount.java @@ -1,5 +1,8 @@ package gift.web.client.dto; +import gift.domain.Member; +import gift.domain.vo.Email; + public class KakaoAccount { private KakaoProfile profile; @@ -30,4 +33,11 @@ public Boolean getEmailValid() { public Boolean getEmailVerified() { return isEmailVerified; } + + public Member toMember() { + return new Member.Builder() + .name(profile.getNickname()) + .email(Email.from(email)) + .build(); + } } From a83c6544e601b9807d799fbf611e06e032dd3353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:23:28 +0900 Subject: [PATCH 15/80] =?UTF-8?q?feat:=20AuthenticationFilter.java=20ignor?= =?UTF-8?q?ePaths=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/login/oauth2/kakao 추가 --- .../java/gift/authentication/filter/AuthenticationFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/gift/authentication/filter/AuthenticationFilter.java b/src/main/java/gift/authentication/filter/AuthenticationFilter.java index 19386bfe2..c95db07d2 100644 --- a/src/main/java/gift/authentication/filter/AuthenticationFilter.java +++ b/src/main/java/gift/authentication/filter/AuthenticationFilter.java @@ -16,7 +16,7 @@ public class AuthenticationFilter extends OncePerRequestFilter { - private final List ignorePaths = List.of("/api/members/login", "/api/members/register"); + private final List ignorePaths = List.of("/api/login/oauth2/kakao"); private final String AUTHORIZATION_HEADER = "Authorization"; private final String BEARER = "Bearer "; private final JwtResolver jwtResolver; From f2e2b9315df5c65a20b843d3bc860bc7855559d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:23:44 +0900 Subject: [PATCH 16/80] =?UTF-8?q?feat:=20CreateMemberRequest.java=20Passwo?= =?UTF-8?q?rd=20=ED=95=84=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/request/member/CreateMemberRequest.java | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java b/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java index 6c7d053ba..53ca2ab7d 100644 --- a/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java +++ b/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java @@ -1,7 +1,6 @@ package gift.web.dto.request.member; import gift.domain.Member; -import gift.web.validation.constraints.Password; import jakarta.validation.constraints.Email; public class CreateMemberRequest { @@ -9,14 +8,10 @@ public class CreateMemberRequest { @Email private String email; - @Password - private String password; - private String name; - public CreateMemberRequest(String email, String password, String name) { + public CreateMemberRequest(String email, String name) { this.email = email; - this.password = password; this.name = name; } @@ -24,10 +19,6 @@ public String getEmail() { return email; } - public String getPassword() { - return password; - } - public String getName() { return name; } @@ -35,7 +26,6 @@ public String getName() { public Member toEntity() { return new Member.Builder() .email(gift.domain.vo.Email.from(this.email)) - .password(gift.domain.vo.Password.from(this.password)) .name(this.name) .build(); } From 489f789f8da8c82133c76a3e8d09a1640844cf01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:24:09 +0900 Subject: [PATCH 17/80] =?UTF-8?q?feat:=20WebConfig.java=20feignClient=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/config/WebConfig.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/gift/config/WebConfig.java b/src/main/java/gift/config/WebConfig.java index e5dc82b40..41ac4ada0 100644 --- a/src/main/java/gift/config/WebConfig.java +++ b/src/main/java/gift/config/WebConfig.java @@ -1,5 +1,6 @@ package gift.config; +import feign.Client; import gift.authentication.filter.AuthenticationExceptionHandlerFilter; import gift.authentication.filter.AuthenticationFilter; import gift.authentication.token.JwtResolver; @@ -43,4 +44,8 @@ public void addArgumentResolvers(List resolvers) resolvers.add(loginUserArgumentResolver); } + @Bean + public Client feignClient() { + return new Client.Default(null, null); + } } From 8734b45c7ff42e5f1099120d70f5506623057774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:24:44 +0900 Subject: [PATCH 18/80] =?UTF-8?q?feat:=20data.sql=20Member=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20password=20=ED=95=84=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/sql/data.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/sql/data.sql b/src/main/resources/sql/data.sql index f596a8793..567759f52 100644 --- a/src/main/resources/sql/data.sql +++ b/src/main/resources/sql/data.sql @@ -51,11 +51,11 @@ VALUES ('Product 49', 49000, 'https://via.placeholder.com/4950', 3, NOW(), 1, NOW(), 1), ('Product 50', 50000, 'https://via.placeholder.com/5050', 3, NOW(), 1, NOW(), 1); -INSERT INTO member(name, email, password, created_at, updated_at) +INSERT INTO member(name, email, created_at, updated_at) VALUES - ('Member 1', 'member01@gmail.com', 'member010101', NOW(), NOW()), - ('Member 2', 'member02@gmail.com', 'member020202', NOW(), NOW()), - ('Member 3', 'member03@gmail.com', 'member030303', NOW(), NOW()); + ('Member 1', 'member01@gmail.com', NOW(), NOW()), + ('Member 2', 'member02@gmail.com', NOW(), NOW()), + ('Member 3', 'member03@gmail.com', NOW(), NOW()); INSERT INTO wish_product(member_id, product_id, quantity, created_at, created_by, updated_at, updated_by) VALUES From d712d3fc8d8a165d4df730deb6ce926247b51434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:25:15 +0900 Subject: [PATCH 19/80] =?UTF-8?q?feat:=20JwtProvider.java=20Token=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C,=20SocialToken=20=ED=81=B4?= =?UTF-8?q?=EB=A0=88=EC=9E=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/authentication/token/JwtProvider.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/gift/authentication/token/JwtProvider.java b/src/main/java/gift/authentication/token/JwtProvider.java index 1080dd9c9..3c2003b83 100644 --- a/src/main/java/gift/authentication/token/JwtProvider.java +++ b/src/main/java/gift/authentication/token/JwtProvider.java @@ -18,6 +18,7 @@ public class JwtProvider { private long expirationTime; private final String MEMBER_ID_CLAIM_KEY = "memberId"; + private final String SOCIAL_TOKEN_CLAIM_KEY = "socialToken"; public JwtProvider(@Value("${jwt.secretkey}") String secret) { key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); @@ -36,4 +37,18 @@ public Token generateToken(Member member) { .compact()); } + public Token generateToken(Member member, String socialToken) { + long nowMillis = System.currentTimeMillis(); + Date now = new Date(nowMillis); + Date expiration = new Date(nowMillis + expirationTime); + + return Token.from(Jwts.builder() + .claim(MEMBER_ID_CLAIM_KEY, member.getId()) + .claim(SOCIAL_TOKEN_CLAIM_KEY, socialToken) + .issuedAt(now) + .expiration(expiration) + .signWith(key) + .compact()); + } + } From db2e428e2dca015081424be231c0972186076f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:25:34 +0900 Subject: [PATCH 20/80] =?UTF-8?q?feat:=20JwtResolver.java=20resolveSocialT?= =?UTF-8?q?oken=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/authentication/token/JwtResolver.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/gift/authentication/token/JwtResolver.java b/src/main/java/gift/authentication/token/JwtResolver.java index 998484281..faeb82d42 100644 --- a/src/main/java/gift/authentication/token/JwtResolver.java +++ b/src/main/java/gift/authentication/token/JwtResolver.java @@ -14,6 +14,7 @@ public class JwtResolver { private final SecretKey key; private final String MEMBER_ID_CLAIM_KEY = "memberId"; + private final String SOCIAL_TOKEN_CLAIM_KEY = "socialToken"; public JwtResolver(@Value("${jwt.secretkey}") String secret) { this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); @@ -31,4 +32,8 @@ public Optional resolveId(Token token) { return Optional.ofNullable(resolve(token).get(MEMBER_ID_CLAIM_KEY, Long.class)); } + public Optional resolveSocialToken(Token token) { + return Optional.ofNullable(resolve(token).get(SOCIAL_TOKEN_CLAIM_KEY, String.class)); + } + } From 6d6c1ae35d33fb2e519bc4b5629b1ac49ee33258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:26:16 +0900 Subject: [PATCH 21/80] =?UTF-8?q?refactor:=20MemberService.java=20findOrCr?= =?UTF-8?q?eateMember=20=ED=9A=8C=EC=9B=90=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C=20dto=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/service/MemberService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/gift/service/MemberService.java b/src/main/java/gift/service/MemberService.java index f092f2130..ec7699cee 100644 --- a/src/main/java/gift/service/MemberService.java +++ b/src/main/java/gift/service/MemberService.java @@ -40,7 +40,7 @@ public ReadMemberResponse readMember(Long id) { public Member findOrCreateMember(KakaoAccount kakaoAccount) { String email = kakaoAccount.getEmail(); return memberRepository.findByEmail(Email.from(email)) - .orElseGet(() -> memberRepository.save(Member.from(kakaoAccount))); + .orElseGet(() -> memberRepository.save(kakaoAccount.toMember())); } public void deleteMember(Long id) { From 60a31a455069e3d82d1fba46f725ec4d1ba0a72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:26:36 +0900 Subject: [PATCH 22/80] =?UTF-8?q?feat:=20ReadMemberResponse.java=20passwor?= =?UTF-8?q?d=20=ED=95=84=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/response/member/ReadMemberResponse.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java b/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java index adfd869fd..1be0d317c 100644 --- a/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java +++ b/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java @@ -6,18 +6,16 @@ public class ReadMemberResponse { private Long id; private String email; - private String password; private String name; - private ReadMemberResponse(Long id, String email, String password, String name) { + private ReadMemberResponse(Long id, String email, String name) { this.id = id; this.email = email; - this.password = password; this.name = name; } public static ReadMemberResponse fromEntity(Member member) { - return new ReadMemberResponse(member.getId(), member.getEmail().getValue(), member.getPassword().getValue(), + return new ReadMemberResponse(member.getId(), member.getEmail().getValue(), member.getName()); } @@ -29,10 +27,6 @@ public String getEmail() { return email; } - public String getPassword() { - return password; - } - public String getName() { return name; } From f5e2c2da38b1a22221de08840300b644a9f5dce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:26:56 +0900 Subject: [PATCH 23/80] =?UTF-8?q?remove:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoginRequest.java - Password.java - schema.sql --- src/main/java/gift/domain/vo/Password.java | 48 ------------------- .../gift/web/dto/request/LoginRequest.java | 23 --------- src/main/resources/sql/schema.sql | 25 ---------- 3 files changed, 96 deletions(-) delete mode 100644 src/main/java/gift/domain/vo/Password.java delete mode 100644 src/main/java/gift/web/dto/request/LoginRequest.java delete mode 100644 src/main/resources/sql/schema.sql diff --git a/src/main/java/gift/domain/vo/Password.java b/src/main/java/gift/domain/vo/Password.java deleted file mode 100644 index d72f9804b..000000000 --- a/src/main/java/gift/domain/vo/Password.java +++ /dev/null @@ -1,48 +0,0 @@ -package gift.domain.vo; - -import jakarta.persistence.Column; -import jakarta.persistence.Embeddable; -import java.util.Objects; - -@Embeddable -public class Password { - - @Column(name = "password", nullable = false) - private String value; - - protected Password() { - } - - private Password(String value) { - this.value = value; - } - - public static Password from(String password) { - return new Password(password); - } - - public boolean matches(String password) { - return this.value.equals(password); - } - - public String getValue() { - return value; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Password p = (Password) o; - return Objects.equals(this.value, p.value); - } - - @Override - public int hashCode() { - return Objects.hash(value); - } -} diff --git a/src/main/java/gift/web/dto/request/LoginRequest.java b/src/main/java/gift/web/dto/request/LoginRequest.java deleted file mode 100644 index 7a152dbc1..000000000 --- a/src/main/java/gift/web/dto/request/LoginRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package gift.web.dto.request; - -import jakarta.validation.constraints.Email; - -public class LoginRequest { - - @Email - private final String email; - private final String password; - - public LoginRequest(String email, String password) { - this.email = email; - this.password = password; - } - - public String getEmail() { - return email; - } - - public String getPassword() { - return password; - } -} diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql deleted file mode 100644 index 9eac7df36..000000000 --- a/src/main/resources/sql/schema.sql +++ /dev/null @@ -1,25 +0,0 @@ -DROP TABLE IF EXISTS Product; -CREATE TABLE product ( - product_id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - price INT NOT NULL, - image_url VARCHAR(255) NOT NULL -); - -DROP TABLE IF EXISTS member; -CREATE TABLE member ( - member_id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL UNIQUE, - password VARCHAR(255) NOT NULL -); - -DROP TABLE IF EXISTS wish_product; -CREATE TABLE wish_product ( - wish_product_id BIGINT AUTO_INCREMENT PRIMARY KEY, - member_id BIGINT NOT NULL, - product_id BIGINT NOT NULL, - quantity INT NOT NULL, - FOREIGN KEY (member_id) REFERENCES member(member_id), - FOREIGN KEY (product_id) REFERENCES product(product_id) -); \ No newline at end of file From 2fbf175da94a0acc7a146811f3646da1fd7244a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:27:10 +0900 Subject: [PATCH 24/80] =?UTF-8?q?feat:=20LoginController.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/api/LoginController.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/gift/web/controller/api/LoginController.java diff --git a/src/main/java/gift/web/controller/api/LoginController.java b/src/main/java/gift/web/controller/api/LoginController.java new file mode 100644 index 000000000..78ddfec64 --- /dev/null +++ b/src/main/java/gift/web/controller/api/LoginController.java @@ -0,0 +1,24 @@ +package gift.web.controller.api; + +import gift.service.LoginService; +import gift.web.dto.response.LoginResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/login") +public class LoginController { + + private final LoginService loginService; + + public LoginController(LoginService loginService) { + this.loginService = loginService; + } + + @GetMapping("/oauth2/kakao") + public LoginResponse kakaoLogin(String code){ + return loginService.kakaoLogin(code); + } + +} From 4df68c13061b725a09ce0dac228e95c7d5379549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:27:36 +0900 Subject: [PATCH 25/80] =?UTF-8?q?feat:=20Application.java=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @ConfigurationPropertiesScan - @EnableFeignClients --- src/main/java/gift/Application.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/gift/Application.java b/src/main/java/gift/Application.java index 037286d2c..62085aa31 100644 --- a/src/main/java/gift/Application.java +++ b/src/main/java/gift/Application.java @@ -2,9 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing(auditorAwareRef = "memberIdAuditorAware") +@ConfigurationPropertiesScan +@EnableFeignClients @SpringBootApplication public class Application { From 0e270a12ac8882eb67b226d0601a592dc1d92f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:28:01 +0900 Subject: [PATCH 26/80] =?UTF-8?q?test:=20MemberDummyDataProvider.java=20Pa?= =?UTF-8?q?ssword=20=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/gift/utils/MemberDummyDataProvider.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/gift/utils/MemberDummyDataProvider.java b/src/test/java/gift/utils/MemberDummyDataProvider.java index cf340e8c2..0d1856e94 100644 --- a/src/test/java/gift/utils/MemberDummyDataProvider.java +++ b/src/test/java/gift/utils/MemberDummyDataProvider.java @@ -24,7 +24,7 @@ public void run(int quantity) { } private void doRun(int quantity) { - String sql = "insert into member (name, email, password, created_at, updated_at) values (?, ?, ?, ?, ?)"; + String sql = "insert into member (name, email, created_at, updated_at) values (?, ?, ?, ?)"; jdbcTemplate.batchUpdate(sql, getBatchPreparedStatementSetter(quantity)); } @@ -34,7 +34,6 @@ private static BatchPreparedStatementSetter getBatchPreparedStatementSetter(int public void setValues(PreparedStatement ps, int i) throws SQLException { ps.setString(1, "member" + i); ps.setString(2, "member" + i + "@gmail.com"); - ps.setString(3, "member" + i + "0"); ps.setTimestamp(4, Timestamp.valueOf(LocalDateTime.now())); ps.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now())); } From 512fb5c1663d690c6b107a61975c28489b233dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:28:27 +0900 Subject: [PATCH 27/80] =?UTF-8?q?test:=20MemberServiceTest.java=20MemberSe?= =?UTF-8?q?rvice=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gift/service/MemberServiceTest.java | 32 ++----------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/src/test/java/gift/service/MemberServiceTest.java b/src/test/java/gift/service/MemberServiceTest.java index 09c2e0db6..a43099776 100644 --- a/src/test/java/gift/service/MemberServiceTest.java +++ b/src/test/java/gift/service/MemberServiceTest.java @@ -7,15 +7,11 @@ import static org.mockito.BDDMockito.given; import gift.authentication.token.JwtProvider; -import gift.authentication.token.Token; import gift.domain.Member; import gift.domain.vo.Email; -import gift.domain.vo.Password; import gift.repository.MemberRepository; import gift.repository.WishProductRepository; -import gift.web.dto.request.LoginRequest; import gift.web.dto.request.member.CreateMemberRequest; -import gift.web.dto.response.LoginResponse; import gift.web.dto.response.member.CreateMemberResponse; import gift.web.dto.response.member.ReadMemberResponse; import java.util.Optional; @@ -45,7 +41,7 @@ class MemberServiceTest { @DisplayName("회원 생성 요청이 정상적일 때, 회원을 성공적으로 생성합니다.") void createMember() { //given - CreateMemberRequest request = new CreateMemberRequest("member01@naver.com", "password01", "이름"); + CreateMemberRequest request = new CreateMemberRequest("member01@naver.com", "이름"); given(memberRepository.save(any())).willReturn( new Member.Builder().id(1L).name(request.getName()).email(Email.from(request.getEmail())).build()); @@ -64,8 +60,7 @@ void createMember() { @DisplayName("회원 조회 요청이 정상적일 때, 회원을 성공적으로 조회합니다.") void readMember() { //given - Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).password( - Password.from("password01")).build(); + Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).build(); given(memberRepository.findById(1L)).willReturn(Optional.of(member)); //when @@ -83,8 +78,7 @@ void readMember() { @DisplayName("회원 삭제 요청이 정상적일 때, 회원을 성공적으로 삭제합니다.") void deleteMember() { //given - Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).password( - Password.from("password01")).build(); + Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).build(); given(memberRepository.findById(1L)).willReturn(Optional.of(member)); //when @@ -92,24 +86,4 @@ void deleteMember() { assertDoesNotThrow(() -> memberService.deleteMember(1L)); } - @Test - @DisplayName("로그인 요청이 정상적일 때, 로그인을 성공적으로 수행합니다.") - void login() { - //given - String email = "member01@naver.com"; - String password = "password01"; - Member member = new Member.Builder().id(1L).email(Email.from(email)).password( - Password.from(password)).build(); - - LoginRequest request = new LoginRequest(email, password); - - given(memberRepository.findByEmail(Email.from(email))).willReturn(Optional.of(member)); - given(jwtProvider.generateToken(member)).willReturn(Token.from("token")); - - //when - LoginResponse response = memberService.login(request); - - //then - assertThat(response.getToken()).isNotNull(); - } } \ No newline at end of file From 96c3bc591ece65f3c5df7bc473a707b380099db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:29:03 +0900 Subject: [PATCH 28/80] =?UTF-8?q?test:=20MemberApiControllerTest.java=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Member의 password 필드 삭제로 인한 테스트 코드 수정이 필요함 --- .../api/MemberApiControllerTest.java | 413 ++++++++---------- 1 file changed, 187 insertions(+), 226 deletions(-) diff --git a/src/test/java/gift/web/controller/api/MemberApiControllerTest.java b/src/test/java/gift/web/controller/api/MemberApiControllerTest.java index 1dbea41d9..2518c5000 100644 --- a/src/test/java/gift/web/controller/api/MemberApiControllerTest.java +++ b/src/test/java/gift/web/controller/api/MemberApiControllerTest.java @@ -1,226 +1,187 @@ -package gift.web.controller.api; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertIterableEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gift.authentication.token.Token; -import gift.domain.Member; -import gift.repository.MemberRepository; -import gift.service.MemberService; -import gift.service.WishProductService; -import gift.utils.CategoryDummyDataProvider; -import gift.utils.DatabaseCleanup; -import gift.utils.MemberDummyDataProvider; -import gift.utils.ProductDummyDataProvider; -import gift.utils.WishProductDummyDataProvider; -import gift.web.dto.request.LoginRequest; -import gift.web.dto.request.member.CreateMemberRequest; -import gift.web.dto.request.wishproduct.UpdateWishProductRequest; -import gift.web.dto.response.LoginResponse; -import gift.web.dto.response.member.CreateMemberResponse; -import gift.web.dto.response.member.ReadMemberResponse; -import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; -import gift.web.dto.response.wishproduct.UpdateWishProductResponse; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.data.domain.PageRequest; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.test.context.ActiveProfiles; - -@ActiveProfiles("test") -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -class MemberApiControllerTest { - - @LocalServerPort - private int port; - - @Autowired - private TestRestTemplate restTemplate; - - @Autowired - private MemberDummyDataProvider memberDummyDataProvider; - - @Autowired - private ProductDummyDataProvider productDummyDataProvider; - - @Autowired - private WishProductDummyDataProvider wishProductDummyDataProvider; - - @Autowired - private CategoryDummyDataProvider categoryDummyDataProvider; - - @Autowired - private DatabaseCleanup databaseCleanup; - - @Autowired - private MemberService memberService; - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private WishProductService wishProductService; - - //테스트용 회원 - private Member member; - private Token token; - - @BeforeEach - void setUp() { - insertDummyData(100); - member = getTestMember(1L); - token = getAccessToken(); - } - - private Member getTestMember(Long id) { - return memberRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("ID: " + id +"인 회원이 존재하지 않습니다.")); - } - - private Token getAccessToken() { - LoginRequest loginRequest = new LoginRequest( - member.getEmail().getValue(), - member.getPassword().getValue() - ); - LoginResponse loginResponse = memberService.login(loginRequest); - return loginResponse.getToken(); - } - - private void insertDummyData(int quantity) { - if (quantity < 2) { - throw new IllegalArgumentException("quantity는 2 이상이어야 합니다."); - } - memberDummyDataProvider.run(quantity); - productDummyDataProvider.run(quantity); - wishProductDummyDataProvider.run(quantity); - categoryDummyDataProvider.run(quantity); - } - - @AfterEach - void tearDown() { - databaseCleanup.execute(); - } - - @Test - @DisplayName("회원 생성 요청에 대한 정상 응답") - void createMember() { - //given - CreateMemberRequest request = new CreateMemberRequest("test@gmail.com", "test1234", "test"); - String url = "http://localhost:" + port + "/api/members/register"; - - //when - ResponseEntity response = restTemplate.postForEntity(url, request, CreateMemberResponse.class); - - //then - Long newMemberId = response.getBody().getId(); - ReadMemberResponse findMember = memberService.readMember(newMemberId); - - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(newMemberId).isEqualTo(findMember.getId()), - () -> assertThat(request.getEmail()).isEqualTo(findMember.getEmail()), - () -> assertThat(request.getName()).isEqualTo(findMember.getName()), - () -> assertThat(request.getPassword()).isEqualTo(findMember.getPassword()) - ); - } - - @Test - @DisplayName("로그인 요청에 대한 정상 응답") - void login() { - //given - String url = "http://localhost:" + port + "/api/members/login"; - String email = member.getEmail().getValue(); - String password = member.getPassword().getValue(); - LoginRequest request = new LoginRequest(email, password); - - //when - ResponseEntity response = restTemplate.postForEntity(url, request, LoginResponse.class); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().getToken()).isNotNull() - ); - } - - @Test - @DisplayName("위시 리스트 조회 요청에 대한 정상 응답") - void readWishProduct() { - //given - String url = "http://localhost:" + port + "/api/members/wishlist"; - HttpHeaders httpHeaders = getHttpHeaders(); - HttpEntity httpEntity = new HttpEntity(httpHeaders); - - PageRequest defaultPageRequest = PageRequest.of(0, 10); - ReadAllWishProductsResponse expectedWishProducts = wishProductService.readAllWishProducts(member.getId(), - defaultPageRequest); - - //when - ResponseEntity response = restTemplate.exchange(url, - HttpMethod.GET, httpEntity, ReadAllWishProductsResponse.class); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertIterableEquals(response.getBody().getWishlist(), expectedWishProducts.getWishlist()) - ); - } - - @Test - @DisplayName("위시 리스트 상품 수정 요청에 대한 정상 응답") - void updateWishProduct() { - //given - Long wishProductId = 1L; - String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; - HttpHeaders httpHeaders = getHttpHeaders(); - - UpdateWishProductRequest request = new UpdateWishProductRequest(3); - - HttpEntity httpEntity = new HttpEntity(request, httpHeaders); - - //when - ResponseEntity response = restTemplate.exchange(url, - HttpMethod.PUT, httpEntity, UpdateWishProductResponse.class); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().getQuantity()).isEqualTo(request.getQuantity()) - ); - } - - @Test - @DisplayName("위시 리스트 상품 삭제 요청에 대한 정상 응답") - void deleteWishProduct() { - //given - Long wishProductId = 2L; - String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; - HttpHeaders httpHeaders = getHttpHeaders(); - HttpEntity httpEntity = new HttpEntity(httpHeaders); - - //when - ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, httpEntity, - Void.class); - - //then - assertTrue(response.getStatusCode().is2xxSuccessful()); - } - - private HttpHeaders getHttpHeaders() { - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.setBearerAuth(token.getValue()); - return httpHeaders; - } -} \ No newline at end of file +//package gift.web.controller.api; +// +//import static org.assertj.core.api.Assertions.assertThat; +//import static org.junit.jupiter.api.Assertions.assertAll; +//import static org.junit.jupiter.api.Assertions.assertIterableEquals; +//import static org.junit.jupiter.api.Assertions.assertTrue; +// +//import gift.authentication.token.Token; +//import gift.domain.Member; +//import gift.repository.MemberRepository; +//import gift.service.LoginService; +//import gift.service.MemberService; +//import gift.service.WishProductService; +//import gift.utils.CategoryDummyDataProvider; +//import gift.utils.DatabaseCleanup; +//import gift.utils.MemberDummyDataProvider; +//import gift.utils.ProductDummyDataProvider; +//import gift.utils.WishProductDummyDataProvider; +//import gift.web.dto.request.LoginRequest; +//import gift.web.dto.request.member.CreateMemberRequest; +//import gift.web.dto.request.wishproduct.UpdateWishProductRequest; +//import gift.web.dto.response.LoginResponse; +//import gift.web.dto.response.member.CreateMemberResponse; +//import gift.web.dto.response.member.ReadMemberResponse; +//import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; +//import gift.web.dto.response.wishproduct.UpdateWishProductResponse; +//import org.junit.jupiter.api.AfterEach; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.DisplayName; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +//import org.springframework.boot.test.web.client.TestRestTemplate; +//import org.springframework.boot.test.web.server.LocalServerPort; +//import org.springframework.data.domain.PageRequest; +//import org.springframework.http.HttpEntity; +//import org.springframework.http.HttpHeaders; +//import org.springframework.http.HttpMethod; +//import org.springframework.http.ResponseEntity; +//import org.springframework.test.context.ActiveProfiles; +// +//@ActiveProfiles("test") +//@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +//class MemberApiControllerTest { +// +// @LocalServerPort +// private int port; +// +// @Autowired +// private TestRestTemplate restTemplate; +// +// @Autowired +// private MemberDummyDataProvider memberDummyDataProvider; +// +// @Autowired +// private ProductDummyDataProvider productDummyDataProvider; +// +// @Autowired +// private WishProductDummyDataProvider wishProductDummyDataProvider; +// +// @Autowired +// private CategoryDummyDataProvider categoryDummyDataProvider; +// +// @Autowired +// private DatabaseCleanup databaseCleanup; +// +// @Autowired +// private MemberService memberService; +// +// @Autowired +// private LoginService loginService; +// +// @Autowired +// private MemberRepository memberRepository; +// +// @Autowired +// private WishProductService wishProductService; +// +// //테스트용 회원 +// private Member member; +// private Token token; +// +// @BeforeEach +// void setUp() { +// insertDummyData(100); +// member = getTestMember(1L); +// token = getAccessToken(); +// } +// +// private Member getTestMember(Long id) { +// return memberRepository.findById(id) +// .orElseThrow(() -> new IllegalArgumentException("ID: " + id +"인 회원이 존재하지 않습니다.")); +// } +// +// private Token getAccessToken() { +// LoginRequest loginRequest = new LoginRequest( +// member.getEmail().getValue(), +// ); +// LoginResponse loginResponse = memberService.login(loginRequest); +// return loginResponse.getToken(); +// } +// +// private void insertDummyData(int quantity) { +// if (quantity < 2) { +// throw new IllegalArgumentException("quantity는 2 이상이어야 합니다."); +// } +// memberDummyDataProvider.run(quantity); +// productDummyDataProvider.run(quantity); +// wishProductDummyDataProvider.run(quantity); +// categoryDummyDataProvider.run(quantity); +// } +// +// @AfterEach +// void tearDown() { +// databaseCleanup.execute(); +// } +// +// @Test +// @DisplayName("위시 리스트 조회 요청에 대한 정상 응답") +// void readWishProduct() { +// //given +// String url = "http://localhost:" + port + "/api/members/wishlist"; +// HttpHeaders httpHeaders = getHttpHeaders(); +// HttpEntity httpEntity = new HttpEntity(httpHeaders); +// +// PageRequest defaultPageRequest = PageRequest.of(0, 10); +// ReadAllWishProductsResponse expectedWishProducts = wishProductService.readAllWishProducts(member.getId(), +// defaultPageRequest); +// +// //when +// ResponseEntity response = restTemplate.exchange(url, +// HttpMethod.GET, httpEntity, ReadAllWishProductsResponse.class); +// +// //then +// assertAll( +// () -> assertTrue(response.getStatusCode().is2xxSuccessful()), +// () -> assertIterableEquals(response.getBody().getWishlist(), expectedWishProducts.getWishlist()) +// ); +// } +// +// @Test +// @DisplayName("위시 리스트 상품 수정 요청에 대한 정상 응답") +// void updateWishProduct() { +// //given +// Long wishProductId = 1L; +// String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; +// HttpHeaders httpHeaders = getHttpHeaders(); +// +// UpdateWishProductRequest request = new UpdateWishProductRequest(3); +// +// HttpEntity httpEntity = new HttpEntity(request, httpHeaders); +// +// //when +// ResponseEntity response = restTemplate.exchange(url, +// HttpMethod.PUT, httpEntity, UpdateWishProductResponse.class); +// +// //then +// assertAll( +// () -> assertTrue(response.getStatusCode().is2xxSuccessful()), +// () -> assertThat(response.getBody().getQuantity()).isEqualTo(request.getQuantity()) +// ); +// } +// +// @Test +// @DisplayName("위시 리스트 상품 삭제 요청에 대한 정상 응답") +// void deleteWishProduct() { +// //given +// Long wishProductId = 2L; +// String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; +// HttpHeaders httpHeaders = getHttpHeaders(); +// HttpEntity httpEntity = new HttpEntity(httpHeaders); +// +// //when +// ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, httpEntity, +// Void.class); +// +// //then +// assertTrue(response.getStatusCode().is2xxSuccessful()); +// } +// +// private HttpHeaders getHttpHeaders() { +// HttpHeaders httpHeaders = new HttpHeaders(); +// httpHeaders.setBearerAuth(token.getValue()); +// return httpHeaders; +// } +//} \ No newline at end of file From 0fd0b11fafb4bdfcea82a4d0ed47b34a1d9d75c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 16:57:25 +0900 Subject: [PATCH 29/80] =?UTF-8?q?feat:=20MemberApiController.java=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=ED=9A=8C=EC=9B=90=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/MemberApiController.java | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/src/main/java/gift/web/controller/api/MemberApiController.java b/src/main/java/gift/web/controller/api/MemberApiController.java index f0e468347..758d0e5fc 100644 --- a/src/main/java/gift/web/controller/api/MemberApiController.java +++ b/src/main/java/gift/web/controller/api/MemberApiController.java @@ -4,15 +4,10 @@ import gift.service.MemberService; import gift.service.WishProductService; import gift.web.dto.MemberDetails; -import gift.web.dto.request.LoginRequest; -import gift.web.dto.request.member.CreateMemberRequest; import gift.web.dto.request.wishproduct.UpdateWishProductRequest; -import gift.web.dto.response.LoginResponse; -import gift.web.dto.response.member.CreateMemberResponse; +import gift.web.dto.response.member.ReadMemberResponse; import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; import gift.web.dto.response.wishproduct.UpdateWishProductResponse; -import java.net.URI; -import java.net.URISyntaxException; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; @@ -20,7 +15,6 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -38,19 +32,9 @@ public MemberApiController(MemberService memberService, WishProductService wishP this.wishProductService = wishProductService; } - @PostMapping("/register") - public ResponseEntity createMember( - @Validated @RequestBody CreateMemberRequest request) - throws URISyntaxException { - CreateMemberResponse response = memberService.createMember(request); - - URI location = new URI("http://localhost:8080/api/members/" + response.getId()); - return ResponseEntity.created(location).body(response); - } - - @PostMapping("/login") - public ResponseEntity login(@Validated @RequestBody LoginRequest request) { - LoginResponse response = memberService.login(request); + @GetMapping("/{memberId}") + public ResponseEntity readMember(@PathVariable Long memberId) { + ReadMemberResponse response = memberService.readMember(memberId); return ResponseEntity.ok(response); } From 2df743f11ab91e42b267636a7ee38f2200700d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 17:07:35 +0900 Subject: [PATCH 30/80] =?UTF-8?q?chore:=20.gitignore=20application-secret.?= =?UTF-8?q?yml=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 281fb4a5b..701aa5216 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,4 @@ out/ .DS_Store ### secret files ### -#src/main/resources/application-secret.yml \ No newline at end of file +src/main/resources/application-secret.yml \ No newline at end of file From 45ced4845e229f51c3c62d0bffdb7028482079d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 17:07:51 +0900 Subject: [PATCH 31/80] removed cached --- gradlew.bat | 184 +++++++++++----------- src/main/resources/application-secret.yml | 8 - 2 files changed, 92 insertions(+), 100 deletions(-) delete mode 100644 src/main/resources/application-secret.yml diff --git a/gradlew.bat b/gradlew.bat index 6689b85be..93e3f59f1 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. -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=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/resources/application-secret.yml b/src/main/resources/application-secret.yml deleted file mode 100644 index e776ae408..000000000 --- a/src/main/resources/application-secret.yml +++ /dev/null @@ -1,8 +0,0 @@ -spring: - datasource: - url: jdbc:h2:tcp://localhost/~/gift - username: sa - password: - -jwt: - secretKey: db8dc50b1bf35d72218b9961ed669ef3dabbd8ddd617c235baeb8a020c66179661e6e148b8c84163af02394a1c59a0af0c4566dc17325a03c77f2027f16d9b54 From f4a7c776179cf46d8263b021d4d455f48bd0b398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 17:49:41 +0900 Subject: [PATCH 32/80] =?UTF-8?q?docs:=20README.md=20step5=202=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 42b1f59e0..1ee7158b8 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,44 @@ * 토큰 받기를 읽고 액세스 토큰을 추출한다. * 앱 키, 인가 코드가 절대 유출되지 않도록 한다. * 특히 시크릿 키는 GitHub나 클라이언트 코드 등 외부에서 볼 수 있는 곳에 추가하지 않는다. -* (선택) 인가 코드를 받는 방법이 불편한 경우 카카오 로그인 화면을 구현한다. \ No newline at end of file +* (선택) 인가 코드를 받는 방법이 불편한 경우 카카오 로그인 화면을 구현한다. + +### 🚀 2단계 - 주문하기 +*** +#### 기능 요구 사항 +카카오톡 메시지 API를 사용하여 주문하기 기능을 구현한다. + +* 주문할 때 수령인에게 보낼 메시지를 작성할 수 있다. +* 상품 옵션과 해당 수량을 선택하여 주문하면 해당 상품 옵션의 수량이 차감된다. +* 해당 상품이 위시 리스트에 있는 경우 위시 리스트에서 삭제한다. +* 나에게 보내기를 읽고 주문 내역을 카카오톡 메시지로 전송한다. + * 메시지는 메시지 템플릿의 기본 템플릿이나 사용자 정의 템플릿을 사용하여 자유롭게 작성한다. + +아래 예시와 같이 HTTP 메시지를 주고받도록 구현한다. + +##### Request +``` +POST /api/orders HTTP/1.1 +Authorization: Bearer {token} +Content-Type: application/json + +{ + "optionId": 1, + "quantity": 2, + "message": "Please handle this order with care." +} +``` + +##### Response +``` +HTTP/1.1 201 Created +Content-Type: application/json + +{ +"id": 1, +"optionId": 1, +"quantity": 2, +"orderDateTime": "2024-07-21T10:00:00", +"message": "Please handle this order with care." +} +``` From c2f5566feee991d5ddbf2f81552786fef9b9de33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:40:27 +0900 Subject: [PATCH 33/80] =?UTF-8?q?feat:=20Password.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/domain/vo/Password.java | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/main/java/gift/domain/vo/Password.java diff --git a/src/main/java/gift/domain/vo/Password.java b/src/main/java/gift/domain/vo/Password.java new file mode 100644 index 000000000..f63d335a2 --- /dev/null +++ b/src/main/java/gift/domain/vo/Password.java @@ -0,0 +1,48 @@ +package gift.domain.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.Objects; + +@Embeddable +public class Password { + + @Column(name = "password") + private String value; + + protected Password() { + } + + private Password(String value) { + this.value = value; + } + + public static Password from(String password) { + return new Password(password); + } + + public boolean matches(String password) { + return this.value.equals(password); + } + + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Password p = (Password) o; + return Objects.equals(this.value, p.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } +} From 1a0e449fa5137cc71b3940fdf7ff48e6534d4fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:40:36 +0900 Subject: [PATCH 34/80] =?UTF-8?q?feat:=20Platform.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/domain/constants/Platform.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/gift/domain/constants/Platform.java diff --git a/src/main/java/gift/domain/constants/Platform.java b/src/main/java/gift/domain/constants/Platform.java new file mode 100644 index 000000000..673c7e3c1 --- /dev/null +++ b/src/main/java/gift/domain/constants/Platform.java @@ -0,0 +1,7 @@ +package gift.domain.constants; + +public enum Platform { + + GIFT, KAKAO + +} From b52b12a54417362fad8b78b415b24aff2d81631e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:40:53 +0900 Subject: [PATCH 35/80] =?UTF-8?q?feat:=20Member.java=20password,=20platfor?= =?UTF-8?q?m=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/domain/Member.java | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/main/java/gift/domain/Member.java b/src/main/java/gift/domain/Member.java index 9807a8417..0cacf9b9a 100644 --- a/src/main/java/gift/domain/Member.java +++ b/src/main/java/gift/domain/Member.java @@ -1,38 +1,66 @@ package gift.domain; import gift.domain.base.BaseTimeEntity; +import gift.domain.constants.Platform; import gift.domain.vo.Email; +import gift.domain.vo.Password; +import gift.web.validation.exception.client.IncorrectPasswordException; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; +@DynamicInsert @Entity public class Member extends BaseTimeEntity { @Embedded private Email email; + @Embedded + private Password password; + @Column(nullable = false) private String name; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + @ColumnDefault("'GIFT'") + private Platform platform; + protected Member() { } public static class Builder extends BaseTimeEntity.Builder { private Email email; + private Password password; private String name; + private Platform platform; public Builder email(Email email) { this.email = email; return this; } + public Builder password(Password password) { + this.password = password; + return this; + } + public Builder name(String name) { this.name = name; return this; } + public Builder platform(Platform platform) { + this.platform = platform; + return this; + } + @Override protected Builder self() { return this; @@ -47,14 +75,30 @@ public Member build() { private Member(Builder builder) { super(builder); email = builder.email; + password = builder.password; name = builder.name; + platform = builder.platform; } public Email getEmail() { return email; } + public Password getPassword() { + return password; + } + public String getName() { return name; } + + public Platform getPlatform() { + return platform; + } + + public void matchPassword(final String password) { + if (!this.password.matches(password)) { + throw new IncorrectPasswordException(); + } + } } From 595ce3dce3140267d04ac9d2a6a29bb1af2f8679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:41:12 +0900 Subject: [PATCH 36/80] =?UTF-8?q?feat:=20MemberDetails.java=20platform=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/web/dto/MemberDetails.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/gift/web/dto/MemberDetails.java b/src/main/java/gift/web/dto/MemberDetails.java index 82b07bdfc..c6ccdbfb9 100644 --- a/src/main/java/gift/web/dto/MemberDetails.java +++ b/src/main/java/gift/web/dto/MemberDetails.java @@ -1,20 +1,23 @@ package gift.web.dto; import gift.domain.Member; +import gift.domain.constants.Platform; import gift.domain.vo.Email; public class MemberDetails { private Long id; private Email email; + private Platform platform; - public MemberDetails(Long id, Email email) { + public MemberDetails(Long id, Email email, Platform platform) { this.id = id; this.email = email; + this.platform = platform; } public static MemberDetails from(Member member) { - return new MemberDetails(member.getId(), member.getEmail()); + return new MemberDetails(member.getId(), member.getEmail(), member.getPlatform()); } public Long getId() { @@ -24,4 +27,8 @@ public Long getId() { public Email getEmail() { return email; } + + public Platform getPlatform() { + return platform; + } } From bdad4aa354c28b572ccd9a22731ae4f311037ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:41:32 +0900 Subject: [PATCH 37/80] =?UTF-8?q?feat:=20LoginRequest.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/web/dto/request/LoginRequest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/gift/web/dto/request/LoginRequest.java diff --git a/src/main/java/gift/web/dto/request/LoginRequest.java b/src/main/java/gift/web/dto/request/LoginRequest.java new file mode 100644 index 000000000..48298041e --- /dev/null +++ b/src/main/java/gift/web/dto/request/LoginRequest.java @@ -0,0 +1,27 @@ +package gift.web.dto.request; + +import gift.web.validation.constraints.Password; +import jakarta.validation.constraints.Email; + +public class LoginRequest { + + @Email + private final String email; + + @Password + private final String password; + + public LoginRequest(String email, String password) { + this.email = email; + this.password = password; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + +} \ No newline at end of file From 697f03d33019bdeaf608bef3d4f64f7c802e9085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:42:06 +0900 Subject: [PATCH 38/80] =?UTF-8?q?feat:=20MemberService.java=20login()=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/service/MemberService.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/java/gift/service/MemberService.java b/src/main/java/gift/service/MemberService.java index ec7699cee..4a86b1192 100644 --- a/src/main/java/gift/service/MemberService.java +++ b/src/main/java/gift/service/MemberService.java @@ -1,13 +1,18 @@ package gift.service; +import gift.authentication.token.JwtProvider; +import gift.authentication.token.Token; import gift.domain.Member; import gift.domain.vo.Email; import gift.repository.MemberRepository; import gift.repository.WishProductRepository; import gift.web.client.dto.KakaoAccount; +import gift.web.dto.request.LoginRequest; import gift.web.dto.request.member.CreateMemberRequest; +import gift.web.dto.response.LoginResponse; import gift.web.dto.response.member.CreateMemberResponse; import gift.web.dto.response.member.ReadMemberResponse; +import gift.web.validation.exception.client.IncorrectEmailException; import java.util.NoSuchElementException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,10 +23,13 @@ public class MemberService { private final MemberRepository memberRepository; private final WishProductRepository wishProductRepository; + private final JwtProvider jwtProvider; - public MemberService(MemberRepository memberRepository, WishProductRepository wishProductRepository) { + public MemberService(MemberRepository memberRepository, WishProductRepository wishProductRepository, + JwtProvider jwtProvider) { this.memberRepository = memberRepository; this.wishProductRepository = wishProductRepository; + this.jwtProvider = jwtProvider; } @Transactional @@ -48,4 +56,15 @@ public void deleteMember(Long id) { wishProductRepository.deleteAllByMemberId(id); memberRepository.delete(member); } + + public LoginResponse login(LoginRequest request) { + Email email = Email.from(request.getEmail()); + Member member = memberRepository.findByEmail(email).orElseThrow(() -> new IncorrectEmailException(email.getValue())); + + member.matchPassword(request.getPassword()); + + Token token = jwtProvider.generateToken(member); + + return new LoginResponse(token.getValue()); + } } From 11130e830a7cb821c7f5ed0dad1498ceec191932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:42:39 +0900 Subject: [PATCH 39/80] =?UTF-8?q?refactor:=20Product.java=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Builder.validateProductOptionsPresence() --- src/main/java/gift/domain/Product.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/java/gift/domain/Product.java b/src/main/java/gift/domain/Product.java index dff5f0613..139156999 100644 --- a/src/main/java/gift/domain/Product.java +++ b/src/main/java/gift/domain/Product.java @@ -81,12 +81,6 @@ protected Builder self() { public Product build() { return new Product(this); } - - private void validateProductOptionsPresence(List productOptions) { - if (productOptions == null || productOptions.isEmpty()) { - throw new IllegalArgumentException("상품 옵션은 최소 1개 이상이어야 합니다."); - } - } } private Product(Builder builder) { From 3b0b7853fd1eb24019c8bf61787fcf47b668a547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:42:59 +0900 Subject: [PATCH 40/80] =?UTF-8?q?feat:=20CreateMemberRequest.java=20passwo?= =?UTF-8?q?rd=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/request/member/CreateMemberRequest.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java b/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java index 53ca2ab7d..ce0973440 100644 --- a/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java +++ b/src/main/java/gift/web/dto/request/member/CreateMemberRequest.java @@ -1,6 +1,7 @@ package gift.web.dto.request.member; import gift.domain.Member; +import gift.web.validation.constraints.Password; import jakarta.validation.constraints.Email; public class CreateMemberRequest { @@ -8,10 +9,14 @@ public class CreateMemberRequest { @Email private String email; + @Password + private String password; + private String name; - public CreateMemberRequest(String email, String name) { + public CreateMemberRequest(String email, String password, String name) { this.email = email; + this.password = password; this.name = name; } @@ -19,6 +24,10 @@ public String getEmail() { return email; } + public String getPassword() { + return password; + } + public String getName() { return name; } @@ -26,7 +35,9 @@ public String getName() { public Member toEntity() { return new Member.Builder() .email(gift.domain.vo.Email.from(this.email)) + .password(gift.domain.vo.Password.from(password)) .name(this.name) .build(); } + } From 7a68629306e78cc0c7ed56483ead53d264111e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:43:15 +0900 Subject: [PATCH 41/80] =?UTF-8?q?feat:=20ReadMemberResponse.java=20passwor?= =?UTF-8?q?d=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/response/member/ReadMemberResponse.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java b/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java index 1be0d317c..28dcd2c69 100644 --- a/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java +++ b/src/main/java/gift/web/dto/response/member/ReadMemberResponse.java @@ -6,16 +6,18 @@ public class ReadMemberResponse { private Long id; private String email; + private String password; private String name; - private ReadMemberResponse(Long id, String email, String name) { + public ReadMemberResponse(Long id, String email, String password, String name) { this.id = id; this.email = email; + this.password = password; this.name = name; } public static ReadMemberResponse fromEntity(Member member) { - return new ReadMemberResponse(member.getId(), member.getEmail().getValue(), + return new ReadMemberResponse(member.getId(), member.getEmail().getValue(), member.getPassword().getValue(), member.getName()); } @@ -27,6 +29,10 @@ public String getEmail() { return email; } + public String getPassword() { + return password; + } + public String getName() { return name; } From e62bf41761dc647df2abc70304d189b374cb2403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:43:44 +0900 Subject: [PATCH 42/80] =?UTF-8?q?chore:=20data.sql=20member=EC=9D=98=20pas?= =?UTF-8?q?sword=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/sql/data.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/sql/data.sql b/src/main/resources/sql/data.sql index 567759f52..2418e5321 100644 --- a/src/main/resources/sql/data.sql +++ b/src/main/resources/sql/data.sql @@ -51,11 +51,11 @@ VALUES ('Product 49', 49000, 'https://via.placeholder.com/4950', 3, NOW(), 1, NOW(), 1), ('Product 50', 50000, 'https://via.placeholder.com/5050', 3, NOW(), 1, NOW(), 1); -INSERT INTO member(name, email, created_at, updated_at) +INSERT INTO member(name, password, email, created_at, updated_at) VALUES - ('Member 1', 'member01@gmail.com', NOW(), NOW()), - ('Member 2', 'member02@gmail.com', NOW(), NOW()), - ('Member 3', 'member03@gmail.com', NOW(), NOW()); + ('Member 1', 'password01', 'member01@gmail.com', NOW(), NOW()), + ('Member 2', 'password02', 'member02@gmail.com', NOW(), NOW()), + ('Member 3', 'password03', 'member03@gmail.com', NOW(), NOW()); INSERT INTO wish_product(member_id, product_id, quantity, created_at, created_by, updated_at, updated_by) VALUES From 8acbdebb7870bddab143e7296ce00c50e6e26580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:44:13 +0900 Subject: [PATCH 43/80] =?UTF-8?q?feat:=20AuthenticationFilter.java=20ignor?= =?UTF-8?q?ePaths=20=EC=B6=94=EA=B0=80=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/authentication/filter/AuthenticationFilter.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/gift/authentication/filter/AuthenticationFilter.java b/src/main/java/gift/authentication/filter/AuthenticationFilter.java index c95db07d2..934a09a3a 100644 --- a/src/main/java/gift/authentication/filter/AuthenticationFilter.java +++ b/src/main/java/gift/authentication/filter/AuthenticationFilter.java @@ -16,7 +16,10 @@ public class AuthenticationFilter extends OncePerRequestFilter { - private final List ignorePaths = List.of("/api/login/oauth2/kakao"); + private final List ignorePaths = List.of( + "/api/members/login", + "/api/members/register", + "/api/login/oauth2/kakao"); private final String AUTHORIZATION_HEADER = "Authorization"; private final String BEARER = "Bearer "; private final JwtResolver jwtResolver; From 9ef54f6af93051aa8fd1f9b6a5c537dbdef24ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:44:46 +0900 Subject: [PATCH 44/80] =?UTF-8?q?refactor:=20KakaoAccount.java=20Member=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/web/client/dto/KakaoAccount.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/gift/web/client/dto/KakaoAccount.java b/src/main/java/gift/web/client/dto/KakaoAccount.java index 915204a4e..2c06d3b29 100644 --- a/src/main/java/gift/web/client/dto/KakaoAccount.java +++ b/src/main/java/gift/web/client/dto/KakaoAccount.java @@ -1,6 +1,7 @@ package gift.web.client.dto; import gift.domain.Member; +import gift.domain.constants.Platform; import gift.domain.vo.Email; public class KakaoAccount { @@ -38,6 +39,7 @@ public Member toMember() { return new Member.Builder() .name(profile.getNickname()) .email(Email.from(email)) + .platform(Platform.KAKAO) .build(); } } From 9ef88e914287f851e6558703debc68716497f3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:45:13 +0900 Subject: [PATCH 45/80] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gift/service/MemberServiceTest.java | 32 +- .../gift/utils/MemberDummyDataProvider.java | 3 +- .../api/MemberApiControllerTest.java | 413 ++++++++++-------- 3 files changed, 257 insertions(+), 191 deletions(-) diff --git a/src/test/java/gift/service/MemberServiceTest.java b/src/test/java/gift/service/MemberServiceTest.java index a43099776..82e617073 100644 --- a/src/test/java/gift/service/MemberServiceTest.java +++ b/src/test/java/gift/service/MemberServiceTest.java @@ -7,11 +7,15 @@ import static org.mockito.BDDMockito.given; import gift.authentication.token.JwtProvider; +import gift.authentication.token.Token; import gift.domain.Member; import gift.domain.vo.Email; +import gift.domain.vo.Password; import gift.repository.MemberRepository; import gift.repository.WishProductRepository; +import gift.web.dto.request.LoginRequest; import gift.web.dto.request.member.CreateMemberRequest; +import gift.web.dto.response.LoginResponse; import gift.web.dto.response.member.CreateMemberResponse; import gift.web.dto.response.member.ReadMemberResponse; import java.util.Optional; @@ -41,7 +45,7 @@ class MemberServiceTest { @DisplayName("회원 생성 요청이 정상적일 때, 회원을 성공적으로 생성합니다.") void createMember() { //given - CreateMemberRequest request = new CreateMemberRequest("member01@naver.com", "이름"); + CreateMemberRequest request = new CreateMemberRequest("member01@naver.com", "password01", "이름"); given(memberRepository.save(any())).willReturn( new Member.Builder().id(1L).name(request.getName()).email(Email.from(request.getEmail())).build()); @@ -60,7 +64,8 @@ void createMember() { @DisplayName("회원 조회 요청이 정상적일 때, 회원을 성공적으로 조회합니다.") void readMember() { //given - Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).build(); + Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).password( + Password.from("password01")).build(); given(memberRepository.findById(1L)).willReturn(Optional.of(member)); //when @@ -78,7 +83,8 @@ void readMember() { @DisplayName("회원 삭제 요청이 정상적일 때, 회원을 성공적으로 삭제합니다.") void deleteMember() { //given - Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).build(); + Member member = new Member.Builder().id(1L).name("이름").email(Email.from("member01@naver.com")).password( + Password.from("password01")).build(); given(memberRepository.findById(1L)).willReturn(Optional.of(member)); //when @@ -86,4 +92,24 @@ void deleteMember() { assertDoesNotThrow(() -> memberService.deleteMember(1L)); } + @Test + @DisplayName("로그인 요청이 정상적일 때, 로그인을 성공적으로 수행합니다.") + void login() { + //given + String email = "member01@naver.com"; + String password = "password01"; + Member member = new Member.Builder().id(1L).email(Email.from(email)).password( + Password.from(password)).build(); + + LoginRequest request = new LoginRequest(email, password); + + given(memberRepository.findByEmail(Email.from(email))).willReturn(Optional.of(member)); + given(jwtProvider.generateToken(member)).willReturn(Token.from("token")); + + //when + LoginResponse response = memberService.login(request); + + //then + assertThat(response.getAccessToken()).isNotNull(); + } } \ No newline at end of file diff --git a/src/test/java/gift/utils/MemberDummyDataProvider.java b/src/test/java/gift/utils/MemberDummyDataProvider.java index 0d1856e94..cf340e8c2 100644 --- a/src/test/java/gift/utils/MemberDummyDataProvider.java +++ b/src/test/java/gift/utils/MemberDummyDataProvider.java @@ -24,7 +24,7 @@ public void run(int quantity) { } private void doRun(int quantity) { - String sql = "insert into member (name, email, created_at, updated_at) values (?, ?, ?, ?)"; + String sql = "insert into member (name, email, password, created_at, updated_at) values (?, ?, ?, ?, ?)"; jdbcTemplate.batchUpdate(sql, getBatchPreparedStatementSetter(quantity)); } @@ -34,6 +34,7 @@ private static BatchPreparedStatementSetter getBatchPreparedStatementSetter(int public void setValues(PreparedStatement ps, int i) throws SQLException { ps.setString(1, "member" + i); ps.setString(2, "member" + i + "@gmail.com"); + ps.setString(3, "member" + i + "0"); ps.setTimestamp(4, Timestamp.valueOf(LocalDateTime.now())); ps.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now())); } diff --git a/src/test/java/gift/web/controller/api/MemberApiControllerTest.java b/src/test/java/gift/web/controller/api/MemberApiControllerTest.java index 2518c5000..85204f1ea 100644 --- a/src/test/java/gift/web/controller/api/MemberApiControllerTest.java +++ b/src/test/java/gift/web/controller/api/MemberApiControllerTest.java @@ -1,187 +1,226 @@ -//package gift.web.controller.api; -// -//import static org.assertj.core.api.Assertions.assertThat; -//import static org.junit.jupiter.api.Assertions.assertAll; -//import static org.junit.jupiter.api.Assertions.assertIterableEquals; -//import static org.junit.jupiter.api.Assertions.assertTrue; -// -//import gift.authentication.token.Token; -//import gift.domain.Member; -//import gift.repository.MemberRepository; -//import gift.service.LoginService; -//import gift.service.MemberService; -//import gift.service.WishProductService; -//import gift.utils.CategoryDummyDataProvider; -//import gift.utils.DatabaseCleanup; -//import gift.utils.MemberDummyDataProvider; -//import gift.utils.ProductDummyDataProvider; -//import gift.utils.WishProductDummyDataProvider; -//import gift.web.dto.request.LoginRequest; -//import gift.web.dto.request.member.CreateMemberRequest; -//import gift.web.dto.request.wishproduct.UpdateWishProductRequest; -//import gift.web.dto.response.LoginResponse; -//import gift.web.dto.response.member.CreateMemberResponse; -//import gift.web.dto.response.member.ReadMemberResponse; -//import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; -//import gift.web.dto.response.wishproduct.UpdateWishProductResponse; -//import org.junit.jupiter.api.AfterEach; -//import org.junit.jupiter.api.BeforeEach; -//import org.junit.jupiter.api.DisplayName; -//import org.junit.jupiter.api.Test; -//import org.springframework.beans.factory.annotation.Autowired; -//import org.springframework.boot.test.context.SpringBootTest; -//import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -//import org.springframework.boot.test.web.client.TestRestTemplate; -//import org.springframework.boot.test.web.server.LocalServerPort; -//import org.springframework.data.domain.PageRequest; -//import org.springframework.http.HttpEntity; -//import org.springframework.http.HttpHeaders; -//import org.springframework.http.HttpMethod; -//import org.springframework.http.ResponseEntity; -//import org.springframework.test.context.ActiveProfiles; -// -//@ActiveProfiles("test") -//@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -//class MemberApiControllerTest { -// -// @LocalServerPort -// private int port; -// -// @Autowired -// private TestRestTemplate restTemplate; -// -// @Autowired -// private MemberDummyDataProvider memberDummyDataProvider; -// -// @Autowired -// private ProductDummyDataProvider productDummyDataProvider; -// -// @Autowired -// private WishProductDummyDataProvider wishProductDummyDataProvider; -// -// @Autowired -// private CategoryDummyDataProvider categoryDummyDataProvider; -// -// @Autowired -// private DatabaseCleanup databaseCleanup; -// -// @Autowired -// private MemberService memberService; -// -// @Autowired -// private LoginService loginService; -// -// @Autowired -// private MemberRepository memberRepository; -// -// @Autowired -// private WishProductService wishProductService; -// -// //테스트용 회원 -// private Member member; -// private Token token; -// -// @BeforeEach -// void setUp() { -// insertDummyData(100); -// member = getTestMember(1L); -// token = getAccessToken(); -// } -// -// private Member getTestMember(Long id) { -// return memberRepository.findById(id) -// .orElseThrow(() -> new IllegalArgumentException("ID: " + id +"인 회원이 존재하지 않습니다.")); -// } -// -// private Token getAccessToken() { -// LoginRequest loginRequest = new LoginRequest( -// member.getEmail().getValue(), -// ); -// LoginResponse loginResponse = memberService.login(loginRequest); -// return loginResponse.getToken(); -// } -// -// private void insertDummyData(int quantity) { -// if (quantity < 2) { -// throw new IllegalArgumentException("quantity는 2 이상이어야 합니다."); -// } -// memberDummyDataProvider.run(quantity); -// productDummyDataProvider.run(quantity); -// wishProductDummyDataProvider.run(quantity); -// categoryDummyDataProvider.run(quantity); -// } -// -// @AfterEach -// void tearDown() { -// databaseCleanup.execute(); -// } -// -// @Test -// @DisplayName("위시 리스트 조회 요청에 대한 정상 응답") -// void readWishProduct() { -// //given -// String url = "http://localhost:" + port + "/api/members/wishlist"; -// HttpHeaders httpHeaders = getHttpHeaders(); -// HttpEntity httpEntity = new HttpEntity(httpHeaders); -// -// PageRequest defaultPageRequest = PageRequest.of(0, 10); -// ReadAllWishProductsResponse expectedWishProducts = wishProductService.readAllWishProducts(member.getId(), -// defaultPageRequest); -// -// //when -// ResponseEntity response = restTemplate.exchange(url, -// HttpMethod.GET, httpEntity, ReadAllWishProductsResponse.class); -// -// //then -// assertAll( -// () -> assertTrue(response.getStatusCode().is2xxSuccessful()), -// () -> assertIterableEquals(response.getBody().getWishlist(), expectedWishProducts.getWishlist()) -// ); -// } -// -// @Test -// @DisplayName("위시 리스트 상품 수정 요청에 대한 정상 응답") -// void updateWishProduct() { -// //given -// Long wishProductId = 1L; -// String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; -// HttpHeaders httpHeaders = getHttpHeaders(); -// -// UpdateWishProductRequest request = new UpdateWishProductRequest(3); -// -// HttpEntity httpEntity = new HttpEntity(request, httpHeaders); -// -// //when -// ResponseEntity response = restTemplate.exchange(url, -// HttpMethod.PUT, httpEntity, UpdateWishProductResponse.class); -// -// //then -// assertAll( -// () -> assertTrue(response.getStatusCode().is2xxSuccessful()), -// () -> assertThat(response.getBody().getQuantity()).isEqualTo(request.getQuantity()) -// ); -// } -// -// @Test -// @DisplayName("위시 리스트 상품 삭제 요청에 대한 정상 응답") -// void deleteWishProduct() { -// //given -// Long wishProductId = 2L; -// String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; -// HttpHeaders httpHeaders = getHttpHeaders(); -// HttpEntity httpEntity = new HttpEntity(httpHeaders); -// -// //when -// ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, httpEntity, -// Void.class); -// -// //then -// assertTrue(response.getStatusCode().is2xxSuccessful()); -// } -// -// private HttpHeaders getHttpHeaders() { -// HttpHeaders httpHeaders = new HttpHeaders(); -// httpHeaders.setBearerAuth(token.getValue()); -// return httpHeaders; -// } -//} \ No newline at end of file +package gift.web.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gift.authentication.token.Token; +import gift.domain.Member; +import gift.repository.MemberRepository; +import gift.service.MemberService; +import gift.service.WishProductService; +import gift.utils.CategoryDummyDataProvider; +import gift.utils.DatabaseCleanup; +import gift.utils.MemberDummyDataProvider; +import gift.utils.ProductDummyDataProvider; +import gift.utils.WishProductDummyDataProvider; +import gift.web.dto.request.LoginRequest; +import gift.web.dto.request.member.CreateMemberRequest; +import gift.web.dto.request.wishproduct.UpdateWishProductRequest; +import gift.web.dto.response.LoginResponse; +import gift.web.dto.response.member.CreateMemberResponse; +import gift.web.dto.response.member.ReadMemberResponse; +import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; +import gift.web.dto.response.wishproduct.UpdateWishProductResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class MemberApiControllerTest { + + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private MemberDummyDataProvider memberDummyDataProvider; + + @Autowired + private ProductDummyDataProvider productDummyDataProvider; + + @Autowired + private WishProductDummyDataProvider wishProductDummyDataProvider; + + @Autowired + private CategoryDummyDataProvider categoryDummyDataProvider; + + @Autowired + private DatabaseCleanup databaseCleanup; + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private WishProductService wishProductService; + + //테스트용 회원 + private Member member; + private Token token; + + @BeforeEach + void setUp() { + insertDummyData(100); + member = getTestMember(1L); + token = getAccessToken(); + } + + private Member getTestMember(Long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("ID: " + id +"인 회원이 존재하지 않습니다.")); + } + + private Token getAccessToken() { + LoginRequest loginRequest = new LoginRequest( + member.getEmail().getValue(), + member.getPassword().getValue() + ); + LoginResponse loginResponse = memberService.login(loginRequest); + return Token.from(loginResponse.getAccessToken()); + } + + private void insertDummyData(int quantity) { + if (quantity < 2) { + throw new IllegalArgumentException("quantity는 2 이상이어야 합니다."); + } + memberDummyDataProvider.run(quantity); + productDummyDataProvider.run(quantity); + wishProductDummyDataProvider.run(quantity); + categoryDummyDataProvider.run(quantity); + } + + @AfterEach + void tearDown() { + databaseCleanup.execute(); + } + + @Test + @DisplayName("회원 생성 요청에 대한 정상 응답") + void createMember() { + //given + CreateMemberRequest request = new CreateMemberRequest("test@gmail.com", "test1234", "test"); + String url = "http://localhost:" + port + "/api/members/register"; + + //when + ResponseEntity response = restTemplate.postForEntity(url, request, CreateMemberResponse.class); + + //then + Long newMemberId = response.getBody().getId(); + ReadMemberResponse findMember = memberService.readMember(newMemberId); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(newMemberId).isEqualTo(findMember.getId()), + () -> assertThat(request.getEmail()).isEqualTo(findMember.getEmail()), + () -> assertThat(request.getName()).isEqualTo(findMember.getName()), + () -> assertThat(request.getPassword()).isEqualTo(findMember.getPassword()) + ); + } + + @Test + @DisplayName("로그인 요청에 대한 정상 응답") + void login() { + //given + String url = "http://localhost:" + port + "/api/members/login"; + String email = member.getEmail().getValue(); + String password = member.getPassword().getValue(); + LoginRequest request = new LoginRequest(email, password); + + //when + ResponseEntity response = restTemplate.postForEntity(url, request, LoginResponse.class); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().getAccessToken()).isNotNull() + ); + } + + @Test + @DisplayName("위시 리스트 조회 요청에 대한 정상 응답") + void readWishProduct() { + //given + String url = "http://localhost:" + port + "/api/members/wishlist"; + HttpHeaders httpHeaders = getHttpHeaders(); + HttpEntity httpEntity = new HttpEntity(httpHeaders); + + PageRequest defaultPageRequest = PageRequest.of(0, 10); + ReadAllWishProductsResponse expectedWishProducts = wishProductService.readAllWishProducts(member.getId(), + defaultPageRequest); + + //when + ResponseEntity response = restTemplate.exchange(url, + HttpMethod.GET, httpEntity, ReadAllWishProductsResponse.class); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertIterableEquals(response.getBody().getWishlist(), expectedWishProducts.getWishlist()) + ); + } + + @Test + @DisplayName("위시 리스트 상품 수정 요청에 대한 정상 응답") + void updateWishProduct() { + //given + Long wishProductId = 1L; + String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; + HttpHeaders httpHeaders = getHttpHeaders(); + + UpdateWishProductRequest request = new UpdateWishProductRequest(3); + + HttpEntity httpEntity = new HttpEntity(request, httpHeaders); + + //when + ResponseEntity response = restTemplate.exchange(url, + HttpMethod.PUT, httpEntity, UpdateWishProductResponse.class); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().getQuantity()).isEqualTo(request.getQuantity()) + ); + } + + @Test + @DisplayName("위시 리스트 상품 삭제 요청에 대한 정상 응답") + void deleteWishProduct() { + //given + Long wishProductId = 2L; + String url = "http://localhost:" + port + "/api/members/wishlist/" + wishProductId; + HttpHeaders httpHeaders = getHttpHeaders(); + HttpEntity httpEntity = new HttpEntity(httpHeaders); + + //when + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, httpEntity, + Void.class); + + //then + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + private HttpHeaders getHttpHeaders() { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setBearerAuth(token.getValue()); + return httpHeaders; + } +} \ No newline at end of file From e0b14df39256eb6ec5406c30447f939810636e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 01:45:33 +0900 Subject: [PATCH 46/80] =?UTF-8?q?feat:=20MemberApiController.java=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85/=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/MemberApiController.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/java/gift/web/controller/api/MemberApiController.java b/src/main/java/gift/web/controller/api/MemberApiController.java index 758d0e5fc..2db0cd4aa 100644 --- a/src/main/java/gift/web/controller/api/MemberApiController.java +++ b/src/main/java/gift/web/controller/api/MemberApiController.java @@ -4,10 +4,16 @@ import gift.service.MemberService; import gift.service.WishProductService; import gift.web.dto.MemberDetails; +import gift.web.dto.request.LoginRequest; +import gift.web.dto.request.member.CreateMemberRequest; import gift.web.dto.request.wishproduct.UpdateWishProductRequest; +import gift.web.dto.response.LoginResponse; +import gift.web.dto.response.member.CreateMemberResponse; import gift.web.dto.response.member.ReadMemberResponse; import gift.web.dto.response.wishproduct.ReadAllWishProductsResponse; import gift.web.dto.response.wishproduct.UpdateWishProductResponse; +import java.net.URI; +import java.net.URISyntaxException; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; @@ -15,6 +21,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -32,6 +39,22 @@ public MemberApiController(MemberService memberService, WishProductService wishP this.wishProductService = wishProductService; } + @PostMapping("/register") + public ResponseEntity createMember( + @Validated @RequestBody CreateMemberRequest request) + throws URISyntaxException { + CreateMemberResponse response = memberService.createMember(request); + + URI location = new URI("http://localhost:8080/api/members/" + response.getId()); + return ResponseEntity.created(location).body(response); + } + + @PostMapping("/login") + public ResponseEntity login(@Validated @RequestBody LoginRequest request) { + LoginResponse response = memberService.login(request); + return ResponseEntity.ok(response); + } + @GetMapping("/{memberId}") public ResponseEntity readMember(@PathVariable Long memberId) { ReadMemberResponse response = memberService.readMember(memberId); From 37cd143e67d32b6e5673c6a877fc1e11c0422f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 11:09:36 +0900 Subject: [PATCH 47/80] =?UTF-8?q?feat:=20ProductViewController.java=20form?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/ProductViewController.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main/java/gift/web/controller/view/ProductViewController.java b/src/main/java/gift/web/controller/view/ProductViewController.java index 2806f7f5d..d13e7df69 100644 --- a/src/main/java/gift/web/controller/view/ProductViewController.java +++ b/src/main/java/gift/web/controller/view/ProductViewController.java @@ -1,6 +1,8 @@ package gift.web.controller.view; +import gift.authentication.annotation.LoginMember; import gift.service.ProductService; +import gift.web.dto.MemberDetails; import gift.web.dto.form.CreateProductForm; import gift.web.dto.response.product.ReadAllProductsResponse; import gift.web.dto.response.product.ReadProductResponse; @@ -12,7 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping; @Controller -@RequestMapping("/view/products") +@RequestMapping("/view") public class ProductViewController { private final ProductService productService; @@ -21,7 +23,7 @@ public ProductViewController(ProductService productService) { this.productService = productService; } - @GetMapping + @GetMapping("/products") public String readAdminPage(Model model) { ReadAllProductsResponse allProductsResponse = productService.readAllProducts(); List products = allProductsResponse.getProducts(); @@ -29,16 +31,32 @@ public String readAdminPage(Model model) { return "admin"; } - @GetMapping("/add") + @GetMapping("/products/add") public String addForm(Model model) { model.addAttribute("product", new CreateProductForm()); return "form/add-product-form"; } - @GetMapping("/{id}") + @GetMapping("/products/{id}") public String editForm(@PathVariable Long id, Model model) { ReadProductResponse product = productService.readProductById(id); model.addAttribute("product", product); return "form/edit-product-form"; } + + @GetMapping("/login") + public String loginForm() { + return "form/login-form"; + } + + @GetMapping("/register") + public String registerForm() { + return "form/register-form"; + } + + @GetMapping("/login-callback") + public String loginCallback(@LoginMember MemberDetails memberDetails, Model model) { + model.addAttribute("member", memberDetails); + return "login-callback"; + } } From 9732f2bd86bd77597405298e2dceb2960352f4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 11:10:05 +0900 Subject: [PATCH 48/80] =?UTF-8?q?feat:=20MemberDetails.java=20getEmailValu?= =?UTF-8?q?e()=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면에서 Email이 주소값으로 나오는 현상 해결 --- src/main/java/gift/web/dto/MemberDetails.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/gift/web/dto/MemberDetails.java b/src/main/java/gift/web/dto/MemberDetails.java index c6ccdbfb9..ffbaeed40 100644 --- a/src/main/java/gift/web/dto/MemberDetails.java +++ b/src/main/java/gift/web/dto/MemberDetails.java @@ -28,6 +28,10 @@ public Email getEmail() { return email; } + public String getEmailValue() { + return email.getValue(); + } + public Platform getPlatform() { return platform; } From c8b954b4612918881f79fb1f8eb7d62691a7b6b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 11:10:16 +0900 Subject: [PATCH 49/80] feat: script.js --- src/main/resources/static/js/script.js | 134 +++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/src/main/resources/static/js/script.js b/src/main/resources/static/js/script.js index 8738a2c8e..9fed7fed9 100644 --- a/src/main/resources/static/js/script.js +++ b/src/main/resources/static/js/script.js @@ -84,3 +84,137 @@ function deleteProductById(productId) { console.error('알 수 없는 에러가 발생했습니다! ', error); }); } + +function giftLogin() { + const form = document.getElementById('loginForm'); + const formData = new FormData(form); + + const data = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + + console.log('Form data:', data); + + fetch('/api/members/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }) + .then(response => { + if (!response.ok) { + return response.json().then(errorData => { + throw new Error(errorData.description); + }); + } + return response.json(); + }) + .then(data => { + alert('로그인이 완료되었습니다!'); + console.log(data); + localStorage.setItem('accessToken', data.accessToken); + + fetch('/view/login-callback', { + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + data.accessToken + } + }) + .then(response => { + if (!response.ok) { + throw new Error('페이지 로드 실패: ' + response.statusText); + } + return response.text(); + }) + .then(html => { + document.write(html); + }) + .catch(error => { + console.error('페이지 로드 실패: ', error); + }); + }) + .catch(error => { + console.error('알 수 없는 에러가 발생했습니다! ', error); + }); +} + +function kakaoLogin() { + fetch('https://kauth.kakao.com/oauth/authorize?client_id=c14cc9f825429533e917e1b1be966e08&redirect_uri=http://localhost:8080/api/login/oauth2/kakao&response_type=code') + .then(response => { + if (!response.ok) { + return response.json().then(errorData => { + throw new Error(errorData.description); + }); + } + return response.json(); + }) + .then(data => { + console.log(data); + localStorage.setItem('accessToken', data.accessToken); + + fetch('/view/login-callback', { + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + data.accessToken + } + }) + .then(response => { + if (!response.ok) { + throw new Error('페이지 로드 실패: ' + response.statusText); + } + return response.text(); + }) + .then(html => { + document.write(html); + }) + .catch(error => { + console.error('페이지 로드 실패: ', error); + }); + }) + .catch(error => { + console.error('알 수 없는 에러가 발생했습니다! ', error); + }); +} + +function registerUser() { + const form = document.getElementById('registerForm'); + const formData = new FormData(form); + + const data = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + + if (data.password !== data.confirmPassword) { + alert('비밀번호와 비밀번호 확인이 일치하지 않습니다.'); + return; + } + + console.log('Form data:', data); + + fetch('/api/members/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }) + .then(response => { + if (!response.ok) { + return response.json().then(errorData => { + throw new Error(errorData.description); + }); + } + return response.json(); + }) + .then(data => { + alert('회원가입이 완료되었습니다!'); + console.log(data); + window.location.href = '/view/login'; + }) + .catch(error => { + console.error('Error:', error); + }); +} \ No newline at end of file From b61599ca707962b76968bbe2267e4cc3b5c49f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 11:10:35 +0900 Subject: [PATCH 50/80] =?UTF-8?q?feat:=20login-callback.html=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/templates/login-callback.html | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/resources/templates/login-callback.html diff --git a/src/main/resources/templates/login-callback.html b/src/main/resources/templates/login-callback.html new file mode 100644 index 000000000..363765c6a --- /dev/null +++ b/src/main/resources/templates/login-callback.html @@ -0,0 +1,28 @@ + + + + + 사용자 정보 + + + +
+

사용자 정보

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + \ No newline at end of file From 74dca820bfed6f8faae1ebee12947ddadbd6f0e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 11:10:41 +0900 Subject: [PATCH 51/80] =?UTF-8?q?feat:=20login-call.html=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/templates/form/login-form.html | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/resources/templates/form/login-form.html diff --git a/src/main/resources/templates/form/login-form.html b/src/main/resources/templates/form/login-form.html new file mode 100644 index 000000000..eeb3b6e3f --- /dev/null +++ b/src/main/resources/templates/form/login-form.html @@ -0,0 +1,31 @@ + + + + + + 로그인 + + + +
+

로그인

+
+
+ + +
+
+ + +
+ +
+
+ +
+ + + \ No newline at end of file From 9093d5a99fe232cb3d038acae5efbcd5b0488c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 11:10:50 +0900 Subject: [PATCH 52/80] =?UTF-8?q?feat:=20register-form.html=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/form/register-form.html | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/main/resources/templates/form/register-form.html diff --git a/src/main/resources/templates/form/register-form.html b/src/main/resources/templates/form/register-form.html new file mode 100644 index 000000000..e612a3d55 --- /dev/null +++ b/src/main/resources/templates/form/register-form.html @@ -0,0 +1,34 @@ + + + + + + 회원가입 + + + +
+

회원가입

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + \ No newline at end of file From c5436da1d599699e02a1bc9512f23673dde9850c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 11:11:04 +0900 Subject: [PATCH 53/80] =?UTF-8?q?refactor:=20index.html=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index ef654ad6c..a41323396 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -10,7 +10,7 @@ - + \ No newline at end of file From 5c338df8eca51ddf0c57adf1ecd3ed632acfdd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Fri, 26 Jul 2024 17:49:41 +0900 Subject: [PATCH 54/80] =?UTF-8?q?docs:=20README.md=20step5=202=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 42b1f59e0..1ee7158b8 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,44 @@ * 토큰 받기를 읽고 액세스 토큰을 추출한다. * 앱 키, 인가 코드가 절대 유출되지 않도록 한다. * 특히 시크릿 키는 GitHub나 클라이언트 코드 등 외부에서 볼 수 있는 곳에 추가하지 않는다. -* (선택) 인가 코드를 받는 방법이 불편한 경우 카카오 로그인 화면을 구현한다. \ No newline at end of file +* (선택) 인가 코드를 받는 방법이 불편한 경우 카카오 로그인 화면을 구현한다. + +### 🚀 2단계 - 주문하기 +*** +#### 기능 요구 사항 +카카오톡 메시지 API를 사용하여 주문하기 기능을 구현한다. + +* 주문할 때 수령인에게 보낼 메시지를 작성할 수 있다. +* 상품 옵션과 해당 수량을 선택하여 주문하면 해당 상품 옵션의 수량이 차감된다. +* 해당 상품이 위시 리스트에 있는 경우 위시 리스트에서 삭제한다. +* 나에게 보내기를 읽고 주문 내역을 카카오톡 메시지로 전송한다. + * 메시지는 메시지 템플릿의 기본 템플릿이나 사용자 정의 템플릿을 사용하여 자유롭게 작성한다. + +아래 예시와 같이 HTTP 메시지를 주고받도록 구현한다. + +##### Request +``` +POST /api/orders HTTP/1.1 +Authorization: Bearer {token} +Content-Type: application/json + +{ + "optionId": 1, + "quantity": 2, + "message": "Please handle this order with care." +} +``` + +##### Response +``` +HTTP/1.1 201 Created +Content-Type: application/json + +{ +"id": 1, +"optionId": 1, +"quantity": 2, +"orderDateTime": "2024-07-21T10:00:00", +"message": "Please handle this order with care." +} +``` From 81385f09705ae8e0c1b75ad03d2bfae926af77d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:16:55 +0900 Subject: [PATCH 55/80] =?UTF-8?q?feat:=20Token.java=20fromBearer()=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/authentication/token/Token.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/gift/authentication/token/Token.java b/src/main/java/gift/authentication/token/Token.java index 8ff73f026..ba75ba991 100644 --- a/src/main/java/gift/authentication/token/Token.java +++ b/src/main/java/gift/authentication/token/Token.java @@ -15,6 +15,10 @@ public static Token from(String value) { return new Token(value); } + public static Token fromBearer(String value) { + return new Token(value.replace("Bearer ", "")); + } + public String getValue() { return value; } From 1bbf57bb9d390dfa6dafc4f2482f56b811dfe068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:18:40 +0900 Subject: [PATCH 56/80] =?UTF-8?q?feat:=20KakaoClient.java=20sendMessage()?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gift/web/client/KakaoClient.java | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/java/gift/web/client/KakaoClient.java b/src/main/java/gift/web/client/KakaoClient.java index 401e1c28e..af3986a9d 100644 --- a/src/main/java/gift/web/client/KakaoClient.java +++ b/src/main/java/gift/web/client/KakaoClient.java @@ -1,10 +1,15 @@ package gift.web.client; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + import gift.authentication.token.KakaoToken; import gift.web.client.dto.KakaoInfo; +import gift.web.client.dto.KakaoMessageResult; import java.net.URI; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; @@ -14,7 +19,7 @@ public interface KakaoClient { @PostMapping KakaoInfo getKakaoInfo( URI uri, - @RequestHeader("Authorization") String accessToken); + @RequestHeader(AUTHORIZATION) String accessToken); @PostMapping KakaoToken getToken( @@ -23,4 +28,17 @@ KakaoToken getToken( @RequestParam("client_id") String clientId, @RequestParam("redirect_uri") String redirectUrl, @RequestParam("grant_type") String grantType); + + /** + * 카카오톡 메시지 - 나에게 보내기 + * @param uri {@link https://kapi.kakao.com/v2/api/talk/memo/default/send} 으로 고정 + * @param accessToken Bearer Token + * @param templateObject 메시지 구성 요소를 담은 객체(Object) 피드, 리스트, 위치, 커머스, 텍스트, 캘린더 중 하나 + */ + @PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + KakaoMessageResult sendMessage( + URI uri, + @RequestHeader(AUTHORIZATION) String accessToken, + @RequestBody String templateObject + ); } From b786ed06bb28934a92b4f37cfe4166b862e365cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:19:51 +0900 Subject: [PATCH 57/80] =?UTF-8?q?feat:=20KakaoCommerce.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/web/client/dto/KakaoCommerce.java | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src/main/java/gift/web/client/dto/KakaoCommerce.java diff --git a/src/main/java/gift/web/client/dto/KakaoCommerce.java b/src/main/java/gift/web/client/dto/KakaoCommerce.java new file mode 100644 index 000000000..aec159aab --- /dev/null +++ b/src/main/java/gift/web/client/dto/KakaoCommerce.java @@ -0,0 +1,207 @@ +package gift.web.client.dto; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import gift.web.dto.response.product.ReadProductResponse; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public class KakaoCommerce { + + private final String objectType = "commerce"; + private Content content; + private Commerce commerce; + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + static class Content { + private String title; + private String imageUrl; + private Integer imageWidth; + private Integer imageHeight; + private String description; + private Link link; + + public Content(String title, String imageUrl, Integer imageWidth, Integer imageHeight, + String description, Link link) { + this.title = title; + this.imageUrl = imageUrl; + this.imageWidth = imageWidth; + this.imageHeight = imageHeight; + this.description = description; + this.link = link; + } + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + static class Link { + private String webUrl; + private String mobileWebUrl; + private String androidExecutionParams; + private String iosExecutionParams; + + public Link(String webUrl, String mobileWebUrl, String androidExecutionParams, + String iosExecutionParams) { + this.webUrl = webUrl; + this.mobileWebUrl = mobileWebUrl; + this.androidExecutionParams = androidExecutionParams; + this.iosExecutionParams = iosExecutionParams; + } + + public String getWebUrl() { + return webUrl; + } + + public String getMobileWebUrl() { + return mobileWebUrl; + } + + public String getAndroidExecutionParams() { + return androidExecutionParams; + } + + public String getIosExecutionParams() { + return iosExecutionParams; + } + } + + public String getTitle() { + return title; + } + + public String getImageUrl() { + return imageUrl; + } + + public Integer getImageWidth() { + return imageWidth; + } + + public Integer getImageHeight() { + return imageHeight; + } + + public String getDescription() { + return description; + } + + public Link getLink() { + return link; + } + } + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + static class Commerce { + private Integer regularPrice; + private Integer discountPrice; + private Integer discountRate; + + public Commerce(Integer regularPrice, Integer discountPrice, Integer discountRate) { + this.regularPrice = regularPrice; + this.discountPrice = discountPrice; + this.discountRate = discountRate; + } + + public Commerce(Integer regularPrice) { + this.regularPrice = regularPrice; + } + + public Integer getRegularPrice() { + return regularPrice; + } + + public Integer getDiscountPrice() { + return discountPrice; + } + + public Integer getDiscountRate() { + return discountRate; + } + } + + public KakaoCommerce() { + + } + + public KakaoCommerce setCommerce(Integer regularPrice, Integer discountPrice, Integer discountRate) { + this.commerce = new Commerce(regularPrice, discountPrice, discountRate); + return this; + } + + public KakaoCommerce setCommerce(Integer regularPrice) { + this.commerce = new Commerce(regularPrice); + return this; + } + + public KakaoCommerce setContent(String title, String imageUrl, Integer imageWidth, Integer imageHeight, String description, String webUrl, String mobileWebUrl, String androidExecutionParams, String iosExecutionParams) { + this.content = new Content(title, imageUrl, imageWidth, imageHeight, description, + new Content.Link(webUrl, mobileWebUrl, androidExecutionParams, iosExecutionParams)); + return this; + } + + public String getObjectType() { + return objectType; + } + + public Content getContent() { + return content; + } + + public Commerce getCommerce() { + return commerce; + } + + /** + * 카카오 상거래 메시지 생성 - 할인 적용 + * @param product 상품 정보 + * @param discountRate 할인율 (0 ~ 100) + * @param message 메시지 + * @return + */ + public static KakaoCommerce of(ReadProductResponse product, int discountRate, String message) { + return new KakaoCommerce().setContent( + product.getName(), + product.getImageUrl().toString(), + 640, + 640, + message, + "https://localhost:8080", + "https://localhost:8080", + "contentId=" + product.getId(), + "contentId=" + product.getId() + ).setCommerce(product.getPrice(), product.getPrice(), 0); + } + + /** + * 카카오 상거래 메시지 생성 - 할인 없음 + * @param product 상품 정보 + * @param message 메시지 + * @return + */ + public static KakaoCommerce of(ReadProductResponse product, String message) { + return new KakaoCommerce().setContent( + product.getName(), + product.getImageUrl().toString(), + 640, + 640, + message, + "https://localhost:8080", + "https://localhost:8080", + "contentId=" + product.getId(), + "contentId=" + product.getId() + ).setCommerce(product.getPrice()); + } + + public String toJson() { + ObjectMapper mapper = new ObjectMapper(); + try { + return "template_object=" + mapper.writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new RuntimeException("JSON 변환 실패", e); + } + } + + public String getContentDescription() { + return content.getDescription(); + } +} + From 859b8bbe79b234b156dd935043df22afe9c55ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:20:03 +0900 Subject: [PATCH 58/80] =?UTF-8?q?feat:=20KakaoMessageResult.java=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/web/client/dto/KakaoMessageResult.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/java/gift/web/client/dto/KakaoMessageResult.java diff --git a/src/main/java/gift/web/client/dto/KakaoMessageResult.java b/src/main/java/gift/web/client/dto/KakaoMessageResult.java new file mode 100644 index 000000000..5517133b0 --- /dev/null +++ b/src/main/java/gift/web/client/dto/KakaoMessageResult.java @@ -0,0 +1,17 @@ +package gift.web.client.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public class KakaoMessageResult { + + private final Integer resultCode; + + @JsonCreator + public KakaoMessageResult(Integer resultCode) { + this.resultCode = resultCode; + } + + public Integer getResultCode() { + return resultCode; + } +} From 099f45f6595a0dd631149e82db536504dd3cdcf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:20:32 +0900 Subject: [PATCH 59/80] =?UTF-8?q?feat:=20CreateOrderRequest.java=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/order/CreateOrderRequest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/java/gift/web/dto/request/order/CreateOrderRequest.java diff --git a/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java b/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java new file mode 100644 index 000000000..f9d7f6d20 --- /dev/null +++ b/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java @@ -0,0 +1,35 @@ +package gift.web.dto.request.order; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public class CreateOrderRequest { + + @NotNull + private final Long optionId; + + @Min(1) + private final Integer quantity; + + @NotEmpty + private final String message; + + public CreateOrderRequest(Long optionId, Integer quantity, String message) { + this.optionId = optionId; + this.quantity = quantity; + this.message = message; + } + + public Long getOptionId() { + return optionId; + } + + public Integer getQuantity() { + return quantity; + } + + public String getMessage() { + return message; + } +} From 15fb1237477f73a9da3d9bc18fa74a4f380b4bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:21:29 +0900 Subject: [PATCH 60/80] =?UTF-8?q?feat:=20KakaoProperties.java=20messageUrl?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xxxUrlAsUri 편의 메서드 추가 --- .../java/gift/config/KakaoProperties.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/gift/config/KakaoProperties.java b/src/main/java/gift/config/KakaoProperties.java index baa7b457c..04355b18b 100644 --- a/src/main/java/gift/config/KakaoProperties.java +++ b/src/main/java/gift/config/KakaoProperties.java @@ -1,5 +1,6 @@ package gift.config; +import java.net.URI; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.ConstructorBinding; @@ -13,10 +14,12 @@ public class KakaoProperties { private final String userInfoUrl; private final String tokenUrl; private final String responseType; + private final String messageUrl; @ConstructorBinding public KakaoProperties(String clientId, String redirectUri, String contentType, - String grantType, String userInfoUrl, String tokenUrl, String responseType) { + String grantType, String userInfoUrl, String tokenUrl, String responseType, + String messageUrl) { this.clientId = clientId; this.redirectUri = redirectUri; this.contentType = contentType; @@ -24,6 +27,7 @@ public KakaoProperties(String clientId, String redirectUri, String contentType, this.userInfoUrl = userInfoUrl; this.tokenUrl = tokenUrl; this.responseType = responseType; + this.messageUrl = messageUrl; } public String getClientId() { @@ -53,4 +57,20 @@ public String getTokenUrl() { public String getResponseType() { return responseType; } + + public String getMessageUrl() { + return messageUrl; + } + + public URI getUserInfoUrlAsUri() { + return URI.create(userInfoUrl); + } + + public URI getTokenUrlAsUri() { + return URI.create(tokenUrl); + } + + public URI getMessageUrlAsUri() { + return URI.create(messageUrl); + } } From 9fe6cb3e9dda7b1ee3bcaec97db09751ad5d6d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:22:46 +0900 Subject: [PATCH 61/80] =?UTF-8?q?feat:=20OrderResponse.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/response/order/OrderResponse.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/main/java/gift/web/dto/response/order/OrderResponse.java diff --git a/src/main/java/gift/web/dto/response/order/OrderResponse.java b/src/main/java/gift/web/dto/response/order/OrderResponse.java new file mode 100644 index 000000000..a718cd79b --- /dev/null +++ b/src/main/java/gift/web/dto/response/order/OrderResponse.java @@ -0,0 +1,50 @@ +package gift.web.dto.response.order; + +public class OrderResponse { + + private Long productId; + + private Long optionId; + + private Integer optionStock; + + private Integer quantity; + + private String productName; + + private String message; + + public OrderResponse(Long productId, Long optionId, Integer optionStock, Integer quantity, + String productName, String message) { + this.productId = productId; + this.optionId = optionId; + this.optionStock = optionStock; + this.quantity = quantity; + this.productName = productName; + this.message = message; + } + + public Long getProductId() { + return productId; + } + + public Long getOptionId() { + return optionId; + } + + public Integer getOptionStock() { + return optionStock; + } + + public Integer getQuantity() { + return quantity; + } + + public String getProductName() { + return productName; + } + + public String getMessage() { + return message; + } +} \ No newline at end of file From 0fd1a92113ac3d0c4796cfbad19ae5583c5e793c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:28:15 +0900 Subject: [PATCH 62/80] =?UTF-8?q?feat:=20ProductOptionService.java=20valid?= =?UTF-8?q?ateExistsProduct()=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/service/ProductOptionService.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/java/gift/service/ProductOptionService.java b/src/main/java/gift/service/ProductOptionService.java index 9663b89a5..dc5f7650c 100644 --- a/src/main/java/gift/service/ProductOptionService.java +++ b/src/main/java/gift/service/ProductOptionService.java @@ -2,6 +2,8 @@ import gift.domain.ProductOption; import gift.repository.ProductOptionRepository; +import gift.repository.ProductRepository; +import gift.web.dto.request.order.CreateOrderRequest; import gift.web.dto.request.productoption.CreateProductOptionRequest; import gift.web.dto.request.productoption.SubtractProductOptionQuantityRequest; import gift.web.dto.request.productoption.UpdateProductOptionRequest; @@ -21,13 +23,17 @@ public class ProductOptionService { private final ProductOptionRepository productOptionRepository; + private final ProductRepository productRepository; - public ProductOptionService(ProductOptionRepository productOptionRepository) { + public ProductOptionService(ProductOptionRepository productOptionRepository, + ProductRepository productRepository) { this.productOptionRepository = productOptionRepository; + this.productRepository = productRepository; } @Transactional public CreateProductOptionResponse createOption(Long productId, CreateProductOptionRequest request) { + validateExistsProduct(productId); String optionName = request.getName(); validateOptionNameExists(productId, optionName); @@ -36,6 +42,15 @@ public CreateProductOptionResponse createOption(Long productId, CreateProductOpt return CreateProductOptionResponse.fromEntity(createdOption); } + /** + * 상품이 존재하는지 확인합니다. + * @param productId 상품 아이디 + */ + private void validateExistsProduct(Long productId) { + productRepository.findById(productId) + .orElseThrow(() -> new ResourceNotFoundException("상품 아이디: ", productId.toString())); + } + /** * 상품 옵션 이름이 이미 존재하는지 확인합니다.
* 이미 존재한다면 {@link AlreadyExistsException} 을 발생시킵니다. @@ -131,6 +146,10 @@ public SubtractProductOptionQuantityResponse subtractOptionStock(Long optionId, return SubtractProductOptionQuantityResponse.fromEntity(option); } + public SubtractProductOptionQuantityResponse subtractOptionStock(CreateOrderRequest request) { + return subtractOptionStock(request.getOptionId(), new SubtractProductOptionQuantityRequest(request.getQuantity())); + } + @Transactional public void deleteOption(Long optionId) { ProductOption option = productOptionRepository.findById(optionId) From 2fcfc261ba799f925944baa4b80754afbc1685e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:28:39 +0900 Subject: [PATCH 63/80] =?UTF-8?q?feat:=20ReadProductResponse.java=20Option?= =?UTF-8?q?s=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/product/ReadProductResponse.java | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/java/gift/web/dto/response/product/ReadProductResponse.java b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java index 48ba51892..ec462447d 100644 --- a/src/main/java/gift/web/dto/response/product/ReadProductResponse.java +++ b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java @@ -2,6 +2,9 @@ import gift.domain.Product; import gift.web.dto.response.category.ReadCategoryResponse; +import gift.web.dto.response.productoption.ReadProductOptionResponse; +import java.util.List; +import java.util.stream.Collectors; public class ReadProductResponse { @@ -9,21 +12,30 @@ public class ReadProductResponse { private final String name; private final Integer price; private final String imageUrl; + private final List productOptions; private final ReadCategoryResponse category; - private ReadProductResponse(Long id, String name, Integer price, String imageUrl, - ReadCategoryResponse category) { + public ReadProductResponse(Long id, String name, Integer price, String imageUrl, + List productOptions, ReadCategoryResponse category) { this.id = id; this.name = name; this.price = price; this.imageUrl = imageUrl; + this.productOptions = productOptions; this.category = category; } public static ReadProductResponse fromEntity(Product product) { - return new ReadProductResponse(product.getId(), product.getName(), product.getPrice(), product.getImageUrl().toString(), ReadCategoryResponse.fromEntity(product.getCategory())); + List productOptions = product.getProductOptions().stream() + .map(productOption -> ReadProductOptionResponse.fromEntity(productOption)) + .collect(Collectors.toList()); + + ReadCategoryResponse category = ReadCategoryResponse.fromEntity(product.getCategory()); + + return new ReadProductResponse(product.getId(), product.getName(), product.getPrice(), product.getImageUrl().toString(), productOptions, category); } + public Long getId() { return id; } @@ -40,6 +52,10 @@ public String getImageUrl() { return imageUrl; } + public List getProductOptions() { + return productOptions; + } + public ReadCategoryResponse getCategory() { return category; } From 08c2fef8ddff2aa39c087031f09ce01fcc93228c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:28:59 +0900 Subject: [PATCH 64/80] =?UTF-8?q?feat:=20OrderService.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/service/OrderService.java | 71 ++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/main/java/gift/service/OrderService.java diff --git a/src/main/java/gift/service/OrderService.java b/src/main/java/gift/service/OrderService.java new file mode 100644 index 000000000..ddd967b1f --- /dev/null +++ b/src/main/java/gift/service/OrderService.java @@ -0,0 +1,71 @@ +package gift.service; + +import gift.authentication.token.JwtResolver; +import gift.authentication.token.Token; +import gift.config.KakaoProperties; +import gift.web.client.KakaoClient; +import gift.web.client.dto.KakaoCommerce; +import gift.web.dto.request.order.CreateOrderRequest; +import gift.web.dto.response.order.OrderResponse; +import gift.web.dto.response.product.ReadProductResponse; +import gift.web.dto.response.productoption.SubtractProductOptionQuantityResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class OrderService { + + private final KakaoClient kakaoClient; + private final JwtResolver jwtResolver; + private final ProductOptionService productOptionService; + private final ProductService productService; + private final KakaoProperties kakaoProperties; + + public OrderService(KakaoClient kakaoClient, JwtResolver jwtResolver, ProductOptionService productOptionService, + ProductService productService, KakaoProperties kakaoProperties) { + this.kakaoClient = kakaoClient; + this.jwtResolver = jwtResolver; + this.productOptionService = productOptionService; + this.productService = productService; + this.kakaoProperties = kakaoProperties; + } + + @Transactional + public OrderResponse createOrder(String accessToken, Long productId, CreateOrderRequest request) { + //상품 옵션 수량 차감 + SubtractProductOptionQuantityResponse subtractOptionStockResponse = productOptionService.subtractOptionStock(request); + + ReadProductResponse product = productService.readProductById(productId); + KakaoCommerce kakaoCommerce = KakaoCommerce.of(product, request.getMessage()); + + sendOrderMessageIfSocialMember(accessToken, kakaoCommerce); + return new OrderResponse( + productId, + request.getOptionId(), + subtractOptionStockResponse.getStock(), + request.getQuantity(), + product.getName(), + request.getMessage()); + } + + /** + * 소셜 로그인을 통해 주문한 경우 카카오톡 메시지를 전송합니다 + * @param accessToken Bearer Token + * @param kakaoCommerce 카카오 상거래 메시지 + */ + private void sendOrderMessageIfSocialMember(String accessToken, KakaoCommerce kakaoCommerce) { + jwtResolver.resolveSocialToken(Token.fromBearer(accessToken)) + .ifPresent(socialToken -> { + String json = kakaoCommerce.toJson(); + kakaoClient.sendMessage( + kakaoProperties.getMessageUrlAsUri(), + getBearerToken(socialToken), + json); + }); + } + + private String getBearerToken(String token) { + return "Bearer " + token; + } +} From d32f7a19ec23ff93c6791ccc21c90e4f61b7ece5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:29:25 +0900 Subject: [PATCH 65/80] =?UTF-8?q?feat:=20ProductApiController.java=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=A3=BC=EB=AC=B8=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/api/ProductApiController.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/gift/web/controller/api/ProductApiController.java b/src/main/java/gift/web/controller/api/ProductApiController.java index 2e2ec7181..5bcd0dee8 100644 --- a/src/main/java/gift/web/controller/api/ProductApiController.java +++ b/src/main/java/gift/web/controller/api/ProductApiController.java @@ -1,15 +1,18 @@ package gift.web.controller.api; import gift.authentication.annotation.LoginMember; +import gift.service.OrderService; import gift.service.ProductOptionService; import gift.service.ProductService; import gift.service.WishProductService; import gift.web.dto.MemberDetails; +import gift.web.dto.request.order.CreateOrderRequest; import gift.web.dto.request.product.CreateProductRequest; import gift.web.dto.request.product.UpdateProductRequest; import gift.web.dto.request.productoption.CreateProductOptionRequest; import gift.web.dto.request.productoption.UpdateProductOptionRequest; import gift.web.dto.request.wishproduct.CreateWishProductRequest; +import gift.web.dto.response.order.OrderResponse; import gift.web.dto.response.product.CreateProductResponse; import gift.web.dto.response.product.ReadAllProductsResponse; import gift.web.dto.response.product.ReadProductResponse; @@ -23,6 +26,7 @@ import java.util.NoSuchElementException; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; @@ -31,6 +35,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -42,11 +47,14 @@ public class ProductApiController { private final ProductService productService; private final WishProductService wishProductService; private final ProductOptionService productOptionService; + private final OrderService orderService; - public ProductApiController(ProductService productService, WishProductService wishProductService, ProductOptionService productOptionService) { + public ProductApiController(ProductService productService, WishProductService wishProductService, ProductOptionService productOptionService, + OrderService orderService) { this.productService = productService; this.wishProductService = wishProductService; this.productOptionService = productOptionService; + this.orderService = orderService; } @GetMapping @@ -114,6 +122,12 @@ public ResponseEntity createOption(@PathVariable Lo return ResponseEntity.created(location).body(response); } + @PostMapping("/{productId}/order") + public ResponseEntity orderProduct(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken, @PathVariable Long productId, @Validated @RequestBody CreateOrderRequest request) { + OrderResponse response = orderService.createOrder(accessToken, productId, request); + return ResponseEntity.ok(response); + } + @PutMapping("/{productId}/options/{optionId}") public ResponseEntity updateOption(@PathVariable Long productId, @PathVariable Long optionId, @Validated @RequestBody UpdateProductOptionRequest request) { UpdateProductOptionResponse response = productOptionService.updateOption(optionId, productId, request); From a2a6af569351bf24f7310831da07102941521385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:29:55 +0900 Subject: [PATCH 66/80] =?UTF-8?q?refactor:=20LoginService.java=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/service/LoginService.java | 35 +++++++------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/src/main/java/gift/service/LoginService.java b/src/main/java/gift/service/LoginService.java index 03ec44394..dc78e70f1 100644 --- a/src/main/java/gift/service/LoginService.java +++ b/src/main/java/gift/service/LoginService.java @@ -9,9 +9,6 @@ import gift.web.client.dto.KakaoAccount; import gift.web.client.dto.KakaoInfo; import gift.web.dto.response.LoginResponse; -import gift.web.validation.exception.client.InvalidCredentialsException; -import java.net.URI; -import java.net.URISyntaxException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -47,30 +44,22 @@ public LoginResponse kakaoLogin(final String authorizationCode){ return new LoginResponse(accessToken.getValue()); } - private KakaoToken getToken(String authorizationCode) { - try { - return kakaoClient.getToken( - new URI(kakaoProperties.getTokenUrl()), - authorizationCode, - kakaoProperties.getClientId(), - kakaoProperties.getRedirectUri(), - kakaoProperties.getGrantType()); - } catch (URISyntaxException e) { - throw new InvalidCredentialsException(e); - } + private KakaoToken getToken(final String authorizationCode) { + return kakaoClient.getToken( + kakaoProperties.getTokenUrlAsUri(), + authorizationCode, + kakaoProperties.getClientId(), + kakaoProperties.getRedirectUri(), + kakaoProperties.getGrantType()); } - private KakaoInfo getInfo(KakaoToken kakaoToken) { - try { - return kakaoClient.getKakaoInfo( - new URI(kakaoProperties.getUserInfoUrl()), - getBearerToken(kakaoToken)); - } catch (URISyntaxException e) { - throw new InvalidCredentialsException(e); - } + private KakaoInfo getInfo(final KakaoToken kakaoToken) { + return kakaoClient.getKakaoInfo( + kakaoProperties.getUserInfoUrlAsUri(), + getBearerToken(kakaoToken)); } - private String getBearerToken(KakaoToken kakaoToken) { + private String getBearerToken(final KakaoToken kakaoToken) { return kakaoToken.getTokenType() + " " + kakaoToken.getAccessToken(); } } From 55a3efa687ec6a3fa7cf15b032f59dde09c7ae52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:41:40 +0900 Subject: [PATCH 67/80] =?UTF-8?q?feat:=20script.js=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kakaoLogin() --- src/main/resources/static/js/script.js | 38 -------------------------- 1 file changed, 38 deletions(-) diff --git a/src/main/resources/static/js/script.js b/src/main/resources/static/js/script.js index 9fed7fed9..98b615c83 100644 --- a/src/main/resources/static/js/script.js +++ b/src/main/resources/static/js/script.js @@ -140,44 +140,6 @@ function giftLogin() { }); } -function kakaoLogin() { - fetch('https://kauth.kakao.com/oauth/authorize?client_id=c14cc9f825429533e917e1b1be966e08&redirect_uri=http://localhost:8080/api/login/oauth2/kakao&response_type=code') - .then(response => { - if (!response.ok) { - return response.json().then(errorData => { - throw new Error(errorData.description); - }); - } - return response.json(); - }) - .then(data => { - console.log(data); - localStorage.setItem('accessToken', data.accessToken); - - fetch('/view/login-callback', { - method: 'GET', - headers: { - 'Authorization': 'Bearer ' + data.accessToken - } - }) - .then(response => { - if (!response.ok) { - throw new Error('페이지 로드 실패: ' + response.statusText); - } - return response.text(); - }) - .then(html => { - document.write(html); - }) - .catch(error => { - console.error('페이지 로드 실패: ', error); - }); - }) - .catch(error => { - console.error('알 수 없는 에러가 발생했습니다! ', error); - }); -} - function registerUser() { const form = document.getElementById('registerForm'); const formData = new FormData(form); From 6425fc14f10cdb39cdc506a6084c76aed3a77a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:42:13 +0900 Subject: [PATCH 68/80] =?UTF-8?q?feat:=20ProductViewController.java=20logi?= =?UTF-8?q?nForm()=20modelAttribute=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clientId, redirectUrl --- .../web/controller/view/ProductViewController.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/gift/web/controller/view/ProductViewController.java b/src/main/java/gift/web/controller/view/ProductViewController.java index d13e7df69..44aa5e0ae 100644 --- a/src/main/java/gift/web/controller/view/ProductViewController.java +++ b/src/main/java/gift/web/controller/view/ProductViewController.java @@ -1,6 +1,7 @@ package gift.web.controller.view; import gift.authentication.annotation.LoginMember; +import gift.config.KakaoProperties; import gift.service.ProductService; import gift.web.dto.MemberDetails; import gift.web.dto.form.CreateProductForm; @@ -19,8 +20,11 @@ public class ProductViewController { private final ProductService productService; - public ProductViewController(ProductService productService) { + private final KakaoProperties kakaoProperties; + + public ProductViewController(ProductService productService, KakaoProperties kakaoProperties) { this.productService = productService; + this.kakaoProperties = kakaoProperties; } @GetMapping("/products") @@ -45,7 +49,9 @@ public String editForm(@PathVariable Long id, Model model) { } @GetMapping("/login") - public String loginForm() { + public String loginForm(Model model) { + model.addAttribute("clientId", kakaoProperties.getClientId()); + model.addAttribute("redirectUri", kakaoProperties.getRedirectUri()); return "form/login-form"; } From e34cbd4a0ebf2044f3871a22f71362676c683bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Sun, 28 Jul 2024 21:42:36 +0900 Subject: [PATCH 69/80] =?UTF-8?q?refactor:=20login-form.html=20=EB=AF=BC?= =?UTF-8?q?=EA=B0=90=ED=95=9C=20=EC=A0=95=EB=B3=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/form/login-form.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/templates/form/login-form.html b/src/main/resources/templates/form/login-form.html index eeb3b6e3f..ac13998d2 100644 --- a/src/main/resources/templates/form/login-form.html +++ b/src/main/resources/templates/form/login-form.html @@ -23,7 +23,7 @@

로그인


From abcf464d14b61e5ee48c1d8d128126d6fcb28081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Tue, 30 Jul 2024 19:11:02 +0900 Subject: [PATCH 70/80] =?UTF-8?q?refactor:=20ReadProductResponse.java=20fr?= =?UTF-8?q?omEntity=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/web/dto/response/product/ReadProductResponse.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/gift/web/dto/response/product/ReadProductResponse.java b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java index ec462447d..85e1af85f 100644 --- a/src/main/java/gift/web/dto/response/product/ReadProductResponse.java +++ b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java @@ -27,8 +27,8 @@ public ReadProductResponse(Long id, String name, Integer price, String imageUrl, public static ReadProductResponse fromEntity(Product product) { List productOptions = product.getProductOptions().stream() - .map(productOption -> ReadProductOptionResponse.fromEntity(productOption)) - .collect(Collectors.toList()); + .map(ReadProductOptionResponse::fromEntity) + .toList(); ReadCategoryResponse category = ReadCategoryResponse.fromEntity(product.getCategory()); From 3da64465d81f849eb0d892cb5f65b6363eadc46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:17:56 +0900 Subject: [PATCH 71/80] =?UTF-8?q?feat:=20Order.java=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/domain/Order.java | 132 +++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/main/java/gift/domain/Order.java diff --git a/src/main/java/gift/domain/Order.java b/src/main/java/gift/domain/Order.java new file mode 100644 index 000000000..6595048f2 --- /dev/null +++ b/src/main/java/gift/domain/Order.java @@ -0,0 +1,132 @@ +package gift.domain; + +import gift.domain.base.BaseEntity; +import gift.domain.base.BaseTimeEntity; +import gift.domain.constants.OrderStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +@Entity +@DynamicInsert +@Table(name = "orders") +public class Order extends BaseEntity { + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Long productOptionId; + + @Column(nullable = false) + private Integer quantity; + + @Column(nullable = false) + @ColumnDefault("''") + private String message; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + @ColumnDefault("'ORDERED'") + private OrderStatus orderStatus; + + protected Order() {} + + public static class Builder extends BaseTimeEntity.Builder { + + private Long memberId; + + private Long productId; + + private Long productOptionId; + + private Integer quantity; + + private String message; + + public Builder memberId(Long memberId) { + this.memberId = memberId; + return this; + } + + public Builder productId(Long productId) { + this.productId = productId; + return this; + } + + public Builder productOptionId(Long productOptionId) { + this.productOptionId = productOptionId; + return this; + } + + public Builder quantity(Integer quantity) { + this.quantity = quantity; + return this; + } + + public Builder message(String message) { + this.message = message; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public Order build() { + return new Order(this); + } + } + + public Order(Builder builder) { + super(builder); + this.memberId = builder.memberId; + this.productId = builder.productId; + this.productOptionId = builder.productOptionId; + this.quantity = builder.quantity; + this.message = builder.message; + } + + public Long getMemberId() { + return memberId; + } + + public Long getProductId() { + return productId; + } + + public Long getProductOptionId() { + return productOptionId; + } + + public Integer getQuantity() { + return quantity; + } + + public String getMessage() { + return message; + } + + public OrderStatus getOrderStatus() { + return orderStatus; + } + + public Order complete() { + this.orderStatus = OrderStatus.COMPLETED; + return this; + } + + public Order cancel() { + this.orderStatus = OrderStatus.CANCELED; + return this; + } +} From 7564f1a4368f3075e80bf1e95f1667819c748784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:18:05 +0900 Subject: [PATCH 72/80] =?UTF-8?q?feat:=20OrderRepository.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/repository/OrderRepository.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/java/gift/repository/OrderRepository.java diff --git a/src/main/java/gift/repository/OrderRepository.java b/src/main/java/gift/repository/OrderRepository.java new file mode 100644 index 000000000..d2b83b7ec --- /dev/null +++ b/src/main/java/gift/repository/OrderRepository.java @@ -0,0 +1,8 @@ +package gift.repository; + +import gift.domain.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderRepository extends JpaRepository { + +} From 3b5f4ff602eea9c8ce0e753aba183a88c88c980b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:18:40 +0900 Subject: [PATCH 73/80] =?UTF-8?q?feat:=20OrderResponse.java=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=ED=95=84=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20fromEntity=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/response/order/OrderResponse.java | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/main/java/gift/web/dto/response/order/OrderResponse.java b/src/main/java/gift/web/dto/response/order/OrderResponse.java index a718cd79b..6601555e4 100644 --- a/src/main/java/gift/web/dto/response/order/OrderResponse.java +++ b/src/main/java/gift/web/dto/response/order/OrderResponse.java @@ -1,29 +1,28 @@ package gift.web.dto.response.order; +import gift.domain.Order; + public class OrderResponse { private Long productId; private Long optionId; - private Integer optionStock; - private Integer quantity; - private String productName; - private String message; - public OrderResponse(Long productId, Long optionId, Integer optionStock, Integer quantity, - String productName, String message) { + public OrderResponse(Long productId, Long optionId, Integer quantity, String message) { this.productId = productId; this.optionId = optionId; - this.optionStock = optionStock; this.quantity = quantity; - this.productName = productName; this.message = message; } + public static OrderResponse from(Order order) { + return new OrderResponse(order.getProductId(), order.getProductOptionId(), order.getQuantity(), order.getMessage()); + } + public Long getProductId() { return productId; } @@ -32,18 +31,10 @@ public Long getOptionId() { return optionId; } - public Integer getOptionStock() { - return optionStock; - } - public Integer getQuantity() { return quantity; } - public String getProductName() { - return productName; - } - public String getMessage() { return message; } From 0ce5bb7f291b4d6b717eda0d4e4c364b204bd551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:18:48 +0900 Subject: [PATCH 74/80] =?UTF-8?q?feat:=20OrderStatus.java=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/domain/constants/OrderStatus.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/gift/domain/constants/OrderStatus.java diff --git a/src/main/java/gift/domain/constants/OrderStatus.java b/src/main/java/gift/domain/constants/OrderStatus.java new file mode 100644 index 000000000..069537257 --- /dev/null +++ b/src/main/java/gift/domain/constants/OrderStatus.java @@ -0,0 +1,9 @@ +package gift.domain.constants; + +public enum OrderStatus { + + ORDERED, //주문됨 + CANCELED, //취소됨 + COMPLETED //배송완료 + +} From b304b28927767306b80fd24a6457835f9fbe1da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:19:05 +0900 Subject: [PATCH 75/80] =?UTF-8?q?feat:=20CreateOrderRequest.java=20toEntit?= =?UTF-8?q?y()=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/request/order/CreateOrderRequest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java b/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java index f9d7f6d20..13e28791f 100644 --- a/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java +++ b/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java @@ -1,5 +1,6 @@ package gift.web.dto.request.order; +import gift.domain.Order; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -21,6 +22,16 @@ public CreateOrderRequest(Long optionId, Integer quantity, String message) { this.message = message; } + public Order toEntity(Long memberId, Long productId) { + return new Order.Builder() + .memberId(memberId) + .productId(productId) + .productOptionId(optionId) + .quantity(quantity) + .message(message) + .build(); + } + public Long getOptionId() { return optionId; } From eed7e4e4666f47c1cd59e39edf683cc14ecd9306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:20:58 +0900 Subject: [PATCH 76/80] =?UTF-8?q?feat:=20OrderService.java=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EC=A0=95=EB=B3=B4=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/service/OrderService.java | 58 ++++++++++++-------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/src/main/java/gift/service/OrderService.java b/src/main/java/gift/service/OrderService.java index ddd967b1f..48705a18b 100644 --- a/src/main/java/gift/service/OrderService.java +++ b/src/main/java/gift/service/OrderService.java @@ -3,12 +3,13 @@ import gift.authentication.token.JwtResolver; import gift.authentication.token.Token; import gift.config.KakaoProperties; +import gift.domain.Order; +import gift.repository.OrderRepository; import gift.web.client.KakaoClient; import gift.web.client.dto.KakaoCommerce; import gift.web.dto.request.order.CreateOrderRequest; import gift.web.dto.response.order.OrderResponse; import gift.web.dto.response.product.ReadProductResponse; -import gift.web.dto.response.productoption.SubtractProductOptionQuantityResponse; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,52 +18,65 @@ public class OrderService { private final KakaoClient kakaoClient; + private final KakaoProperties kakaoProperties; + private final JwtResolver jwtResolver; + private final ProductOptionService productOptionService; private final ProductService productService; - private final KakaoProperties kakaoProperties; + private final OrderRepository orderRepository; public OrderService(KakaoClient kakaoClient, JwtResolver jwtResolver, ProductOptionService productOptionService, - ProductService productService, KakaoProperties kakaoProperties) { + ProductService productService, KakaoProperties kakaoProperties, + OrderRepository orderRepository) { this.kakaoClient = kakaoClient; this.jwtResolver = jwtResolver; this.productOptionService = productOptionService; this.productService = productService; this.kakaoProperties = kakaoProperties; + this.orderRepository = orderRepository; } + /** + * 주문을 생성합니다
+ * 카카오 로그인을 통해 서비스를 이용 중인 회원은 나에게 보내기를 통해 알림을 전송합니다. + * @param accessToken 우리 서비스의 토큰 + * @param productId 구매할 상품 ID + * @param memberId 구매자 ID + * @param request 주문 요청 + * @return + */ @Transactional - public OrderResponse createOrder(String accessToken, Long productId, CreateOrderRequest request) { + public OrderResponse createOrder(String accessToken, Long productId, Long memberId, CreateOrderRequest request) { //상품 옵션 수량 차감 - SubtractProductOptionQuantityResponse subtractOptionStockResponse = productOptionService.subtractOptionStock(request); + productOptionService.subtractOptionStock(request); - ReadProductResponse product = productService.readProductById(productId); - KakaoCommerce kakaoCommerce = KakaoCommerce.of(product, request.getMessage()); + //주문 정보 저장 + Order order = orderRepository.save(request.toEntity(memberId, productId)); - sendOrderMessageIfSocialMember(accessToken, kakaoCommerce); - return new OrderResponse( - productId, - request.getOptionId(), - subtractOptionStockResponse.getStock(), - request.getQuantity(), - product.getName(), - request.getMessage()); + sendOrderMessageIfSocialMember(accessToken, productId, request); + return OrderResponse.from(order); } /** * 소셜 로그인을 통해 주문한 경우 카카오톡 메시지를 전송합니다 - * @param accessToken Bearer Token - * @param kakaoCommerce 카카오 상거래 메시지 + * @param accessToken 우리 서비스의 토큰 + * @param productId 상품 ID + * @param request 주문 요청 */ - private void sendOrderMessageIfSocialMember(String accessToken, KakaoCommerce kakaoCommerce) { + private void sendOrderMessageIfSocialMember(String accessToken, Long productId, CreateOrderRequest request) { jwtResolver.resolveSocialToken(Token.fromBearer(accessToken)) - .ifPresent(socialToken -> { - String json = kakaoCommerce.toJson(); + .ifPresent(socialToken -> kakaoClient.sendMessage( kakaoProperties.getMessageUrlAsUri(), getBearerToken(socialToken), - json); - }); + generateKakaoCommerce(productId, request).toJson() + )); + } + + private KakaoCommerce generateKakaoCommerce(Long productId, CreateOrderRequest request) { + ReadProductResponse productResponse = productService.readProductById(productId); + return KakaoCommerce.of(productResponse, request.getMessage()); } private String getBearerToken(String token) { From 9c32ba41163cdd06ddaff68cd6f117212d01cd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:21:29 +0900 Subject: [PATCH 77/80] =?UTF-8?q?feat:=20ProductApiController.java=20?= =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EC=83=9D=EC=84=B1=20API=20MemberDetail=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/web/controller/api/ProductApiController.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/gift/web/controller/api/ProductApiController.java b/src/main/java/gift/web/controller/api/ProductApiController.java index 5bcd0dee8..5b176fbd5 100644 --- a/src/main/java/gift/web/controller/api/ProductApiController.java +++ b/src/main/java/gift/web/controller/api/ProductApiController.java @@ -123,8 +123,13 @@ public ResponseEntity createOption(@PathVariable Lo } @PostMapping("/{productId}/order") - public ResponseEntity orderProduct(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken, @PathVariable Long productId, @Validated @RequestBody CreateOrderRequest request) { - OrderResponse response = orderService.createOrder(accessToken, productId, request); + public ResponseEntity orderProduct( + @RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken, + @PathVariable Long productId, + @RequestBody @Validated CreateOrderRequest request, + @LoginMember MemberDetails memberDetails + ) { + OrderResponse response = orderService.createOrder(accessToken, productId, memberDetails.getId(), request); return ResponseEntity.ok(response); } From 0ecae3fda649962b40ba69ddff8c2b92e1f03a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:22:23 +0900 Subject: [PATCH 78/80] =?UTF-8?q?style:=20ReadProductResponse.java=20?= =?UTF-8?q?=EC=A4=84=EB=B0=94=EA=BF=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/web/dto/response/product/ReadProductResponse.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/gift/web/dto/response/product/ReadProductResponse.java b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java index 85e1af85f..a9fc4e3c9 100644 --- a/src/main/java/gift/web/dto/response/product/ReadProductResponse.java +++ b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java @@ -4,7 +4,6 @@ import gift.web.dto.response.category.ReadCategoryResponse; import gift.web.dto.response.productoption.ReadProductOptionResponse; import java.util.List; -import java.util.stream.Collectors; public class ReadProductResponse { @@ -26,7 +25,8 @@ public ReadProductResponse(Long id, String name, Integer price, String imageUrl, } public static ReadProductResponse fromEntity(Product product) { - List productOptions = product.getProductOptions().stream() + List productOptions = product.getProductOptions() + .stream() .map(ReadProductOptionResponse::fromEntity) .toList(); @@ -35,7 +35,6 @@ public static ReadProductResponse fromEntity(Product product) { return new ReadProductResponse(product.getId(), product.getName(), product.getPrice(), product.getImageUrl().toString(), productOptions, category); } - public Long getId() { return id; } From 15ca80839d130612e1b3a1e6e7f7b7896596f01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:33:04 +0900 Subject: [PATCH 79/80] =?UTF-8?q?feat:=20OrderRepository.java=20@Repositor?= =?UTF-8?q?y=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/repository/OrderRepository.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/gift/repository/OrderRepository.java b/src/main/java/gift/repository/OrderRepository.java index d2b83b7ec..2cd02aa2b 100644 --- a/src/main/java/gift/repository/OrderRepository.java +++ b/src/main/java/gift/repository/OrderRepository.java @@ -2,7 +2,9 @@ import gift.domain.Order; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository public interface OrderRepository extends JpaRepository { } From 0a7c5652a2462641cbe9c079812974502a4295b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=A0=9C=EB=B2=95?= Date: Wed, 31 Jul 2024 00:33:34 +0900 Subject: [PATCH 80/80] =?UTF-8?q?feat:=20OrderService.java=20@Transactiona?= =?UTF-8?q?l(readOnly=20=3D=20true)=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gift/service/OrderService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/gift/service/OrderService.java b/src/main/java/gift/service/OrderService.java index 48705a18b..3534ae8b6 100644 --- a/src/main/java/gift/service/OrderService.java +++ b/src/main/java/gift/service/OrderService.java @@ -14,7 +14,6 @@ import org.springframework.transaction.annotation.Transactional; @Service -@Transactional(readOnly = true) public class OrderService { private final KakaoClient kakaoClient;